fix production issues
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped

This commit is contained in:
root
2026-08-12 16:48:41 -04:00
parent 53de25120a
commit 8fc88ffc14
117 changed files with 4717 additions and 1443 deletions
+67 -2
View File
@@ -9,6 +9,7 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter }
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
import { requestIdMiddleware } from './middleware/requestId'
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
@@ -132,7 +133,9 @@ export const corsOptions: CorsOptions = {
}
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: '/health', description: 'Liveness health check' },
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
@@ -167,6 +170,7 @@ export function createApp() {
app.use(sanitizeForwardedHeaders)
app.use(requestIdMiddleware)
app.use(metricsMiddleware)
app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) {
@@ -242,7 +246,21 @@ export function createApp() {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' },
}))
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
if (process.env.NODE_ENV !== 'test') {
app.use(morgan((tokens, req, res) => {
const requestId = (req as any).requestId ?? '-'
return JSON.stringify({
level: 'info',
msg: 'http_request',
requestId,
method: tokens.method(req, res),
url: tokens.url(req, res),
status: Number(tokens.status(req, res)),
durationMs: Number(tokens['response-time'](req, res)),
contentLength: tokens.res(req, res, 'content-length'),
})
}))
}
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ─────────────────────────────────────────────
@@ -292,6 +310,53 @@ export function createApp() {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get('/metrics', async (_req, res) => {
try {
const { prisma } = await import('./lib/prisma')
const [pending, published] = await Promise.all([
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
])
setGauge('notification_outbox_pending', pending)
setGauge('notification_outbox_completed', published)
} catch {
/* leave previous gauges */
}
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
res.status(200).send(renderPrometheusText())
})
app.get('/ready', async (_req, res) => {
const { prisma } = await import('./lib/prisma')
const { redis } = await import('./lib/redis')
const { checkStorageReady } = await import('./lib/storage')
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
try {
await prisma.$queryRaw`SELECT 1`
checks.database = 'ok'
} catch {
checks.database = 'error'
}
try {
const pong = await redis.ping()
checks.redis = pong === 'PONG' ? 'ok' : 'error'
} catch {
checks.redis = 'error'
}
try {
await checkStorageReady()
checks.storage = 'ok'
} catch {
checks.storage = 'error'
}
const ready = Object.values(checks).every((v) => v === 'ok')
res.status(ready ? 200 : 503).json({
status: ready ? 'ready' : 'not_ready',
checks,
timestamp: new Date().toISOString(),
})
})
app.get(`${v1}/docs`, (_req, res) => {
if (!publicDocsEnabled) return docsDisabled(_req, res)
res.json({
+51 -177
View File
@@ -1,30 +1,22 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { processNotificationOutbox, sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
const app = createApp()
const app = createApp()
const server = http.createServer(app)
assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
@@ -49,10 +41,9 @@ function getSocketSessionToken(socket: Socket): string | undefined {
)
}
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = getSocketSessionToken(socket)
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
if (!token) return next()
try {
const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
@@ -64,12 +55,9 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) {
socket.join(`user:${userId}`)
}
if (userId) socket.join(`user:${userId}`)
})
// Redis pub/sub → broadcast to connected clients
const subscriber = redis.duplicate()
subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
@@ -84,168 +72,14 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
}
})
// ─── Scheduled jobs ───────────────────────────────────────────
// Embedded jobs only when explicitly enabled (single-process local/dev).
// Production should run `npm run worker` / Compose `api-worker` instead.
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
startOutboxWorker()
startScheduledJobs()
}
// Daily: flag expiring/expired licenses
cron.schedule('0 8 * * *', async () => {
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
for (const c of customers) {
if (!c.licenseExpiry) continue
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const expired = c.licenseExpiry <= new Date()
const expiring = !expired && daysLeft < 90
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
}
}
})
// Hourly: expire trials that ended without payment
cron.schedule('0 * * * *', async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Daily: explicit period-end cancellations. Subscription collections use the
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
// intentionally not scheduled.
cron.schedule('0 1 * * *', async () => {
const nPeriod = await runPeriodEndCancellationJob()
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
cron.schedule('*/15 * * * *', async () => {
const n = await runCollectionsWorker()
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
})
cron.schedule('* * * * *', async () => {
const n = await processNotificationOutbox()
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
})
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (owner) {
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
const reminderDate = now.toISOString().slice(0, 10)
await sendNotification({
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channels: ['IN_APP'],
sourceType: 'maintenance_log',
sourceId: log.id,
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = Number(process.env.API_PORT ?? 4000)
const HOST = process.env.API_HOST ?? '0.0.0.0'
@@ -253,4 +87,44 @@ server.listen(PORT, HOST, () => {
console.log(`[API] Server running on ${HOST}:${PORT}`)
})
let shuttingDown = false
async function shutdown(signal: string) {
if (shuttingDown) return
shuttingDown = true
console.log(`[API] ${signal} received, draining`)
server.close((err) => {
if (err) console.error('[API] HTTP close error:', err.message)
})
try {
io.close()
} catch (err: any) {
console.error('[API] Socket.IO close error:', err?.message ?? err)
}
try {
await subscriber.quit()
} catch {
subscriber.disconnect()
}
try {
await redis.quit()
} catch {
redis.disconnect()
}
try {
await prisma.$disconnect()
} catch (err: any) {
console.error('[API] Prisma disconnect error:', err?.message ?? err)
}
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
export { app, io }
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
describe('idempotencyStore (memory)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test'
process.env.IDEMPOTENCY_STORE = 'memory'
})
it('returns miss then hit for the same fingerprint', async () => {
const key = `k-${Date.now()}`
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
kind: 'hit',
result: { id: 'reservation_1' },
})
})
it('detects fingerprint conflicts', async () => {
const key = `conflict-${Date.now()}`
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
})
})
+56
View File
@@ -0,0 +1,56 @@
import { redis } from '../lib/redis'
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
const DEFAULT_TTL_SECONDS = 15 * 60
function useMemory() {
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
export type IdempotencyHit =
| { kind: 'miss' }
| { kind: 'hit'; result: unknown }
| { kind: 'conflict' }
export async function getIdempotentResult(
scope: string,
key: string,
fingerprint: string,
): Promise<IdempotencyHit> {
const redisKey = `idempotency:${scope}:${key}`
if (useMemory()) {
const cached = MEMORY.get(redisKey)
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: cached.result }
}
const raw = await redis.get(redisKey)
if (!raw) return { kind: 'miss' }
try {
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: parsed.result }
} catch {
return { kind: 'miss' }
}
}
export async function setIdempotentResult(
scope: string,
key: string,
fingerprint: string,
result: unknown,
ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<void> {
const redisKey = `idempotency:${scope}:${key}`
const payload = JSON.stringify({ fingerprint, result })
if (useMemory()) {
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
return
}
await redis.set(redisKey, payload, 'EX', ttlSeconds)
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Optional S3-compatible object storage (MinIO / AWS S3).
* Activated when FILE_STORAGE_DRIVER=s3.
*
* Uses the AWS SDK v3 if installed; otherwise falls back to a clear startup error.
* Add dependency: `@aws-sdk/client-s3`
*/
type S3ClientLike = {
send: (command: unknown) => Promise<unknown>
}
let clientPromise: Promise<S3ClientLike> | null = null
function required(name: string) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required for S3 storage`)
return value
}
async function getClient(): Promise<S3ClientLike> {
if (!clientPromise) {
clientPromise = (async () => {
try {
// Dynamic import keeps local-only installs working without the SDK.
const sdk = await import('@aws-sdk/client-s3')
return new sdk.S3Client({
region: process.env.S3_REGION ?? 'us-east-1',
endpoint: process.env.S3_ENDPOINT || undefined,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
credentials: {
accessKeyId: required('S3_ACCESS_KEY_ID'),
secretAccessKey: required('S3_SECRET_ACCESS_KEY'),
},
}) as S3ClientLike
} catch (err: any) {
throw new Error(
`FILE_STORAGE_DRIVER=s3 requires @aws-sdk/client-s3. Install it in apps/api. (${err?.message ?? err})`,
)
}
})()
}
return clientPromise
}
export async function headBucket() {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(new sdk.HeadBucketCommand({ Bucket: required('S3_BUCKET') }))
}
export async function putObject(key: string, body: Buffer, contentType = 'application/octet-stream') {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.PutObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
Body: body,
ContentType: contentType,
}),
)
}
export async function getObjectBuffer(key: string): Promise<Buffer> {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
const result: any = await client.send(
new sdk.GetObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
const stream = result.Body
if (!stream) throw new Error('Empty S3 object body')
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export async function deleteObject(key: string) {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.DeleteObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
}
export function publicObjectUrl(key: string) {
const base = (process.env.S3_PUBLIC_BASE_URL || process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '')
if (process.env.S3_PUBLIC_BASE_URL) return `${base}/${key.replace(/^\/+/, '')}`
return `${base}/storage/${key.replace(/^\/+/, '')}`
}
+94
View File
@@ -0,0 +1,94 @@
import type { Request, Response, NextFunction } from 'express'
type CounterKey = string
const counters = new Map<CounterKey, number>()
const latencyMs: number[] = []
const MAX_LATENCY_SAMPLES = 2_000
function bump(key: CounterKey, by = 1) {
counters.set(key, (counters.get(key) ?? 0) + by)
}
export function observeHttpRequest(method: string, route: string, statusCode: number, durationMs: number) {
const normalizedRoute = route || 'unknown'
bump(`http_requests_total{method="${method}",route="${normalizedRoute}",status="${statusCode}"}`)
latencyMs.push(durationMs)
if (latencyMs.length > MAX_LATENCY_SAMPLES) latencyMs.splice(0, latencyMs.length - MAX_LATENCY_SAMPLES)
}
export function observeOutboxProcessed(count: number) {
if (count > 0) bump('notification_outbox_processed_total', count)
}
export function setGauge(name: string, value: number) {
counters.set(`gauge:${name}`, value)
}
export function getGauge(name: string): number {
return counters.get(`gauge:${name}`) ?? 0
}
function percentile(sorted: number[], p: number) {
if (sorted.length === 0) return 0
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx]!
}
export function renderPrometheusText(): string {
const lines: string[] = [
'# HELP http_requests_total Total HTTP requests handled by the API',
'# TYPE http_requests_total counter',
]
for (const [key, value] of counters) {
if (key.startsWith('gauge:')) continue
if (key.startsWith('http_requests_total')) {
lines.push(`${key} ${value}`)
}
}
for (const [key, value] of counters) {
if (key.startsWith('notification_outbox_processed_total')) {
lines.push('# HELP notification_outbox_processed_total Notification outbox events completed')
lines.push('# TYPE notification_outbox_processed_total counter')
lines.push(`notification_outbox_processed_total ${value}`)
}
}
const sorted = [...latencyMs].sort((a, b) => a - b)
lines.push('# HELP http_request_duration_ms HTTP request duration percentiles (recent window)')
lines.push('# TYPE http_request_duration_ms gauge')
lines.push(`http_request_duration_ms{quantile="0.5"} ${percentile(sorted, 50)}`)
lines.push(`http_request_duration_ms{quantile="0.95"} ${percentile(sorted, 95)}`)
lines.push(`http_request_duration_ms{quantile="0.99"} ${percentile(sorted, 99)}`)
lines.push('# HELP notification_outbox_pending Notification outbox rows pending dispatch')
lines.push('# TYPE notification_outbox_pending gauge')
lines.push(`notification_outbox_pending ${getGauge('notification_outbox_pending')}`)
lines.push('# HELP notification_outbox_completed Notification outbox rows with status PUBLISHED (DB gauge)')
lines.push('# TYPE notification_outbox_completed gauge')
lines.push(`notification_outbox_completed ${getGauge('notification_outbox_completed')}`)
lines.push('# HELP process_uptime_seconds Process uptime')
lines.push('# TYPE process_uptime_seconds gauge')
lines.push(`process_uptime_seconds ${process.uptime()}`)
return `${lines.join('\n')}\n`
}
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
const started = Date.now()
res.on('finish', () => {
const route = (req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path) || 'unknown'
observeHttpRequest(req.method, route, res.statusCode, Date.now() - started)
})
next()
}
/** Reset in-memory series (tests only). */
export function resetMetricsForTests() {
counters.clear()
latencyMs.length = 0
}
+34 -8
View File
@@ -38,11 +38,11 @@ function isWithinPath(targetPath: string, parentPath: string): boolean {
export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot()
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT && getStorageDriver() === 'local') {
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
}
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'production' && getStorageDriver() === 'local') {
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) {
@@ -60,9 +60,30 @@ export function assertStorageConfiguration(): string {
}
}
if (getStorageDriver() === 's3') {
for (const key of ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
if (!process.env[key]) throw new Error(`${key} is required when FILE_STORAGE_DRIVER=s3`)
}
}
return storageRoot
}
export function getStorageDriver(): 'local' | 's3' {
return process.env.FILE_STORAGE_DRIVER === 's3' ? 's3' : 'local'
}
export async function checkStorageReady(): Promise<void> {
if (getStorageDriver() === 's3') {
const { headBucket } = await import('./objectStorage')
await headBucket()
return
}
const root = assertStorageConfiguration()
fs.mkdirSync(path.join(root, 'public'), { recursive: true })
fs.mkdirSync(path.join(root, 'private'), { recursive: true })
}
function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
@@ -111,19 +132,24 @@ export async function uploadImage(
publicId?: string,
visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> {
const safePublicId = (publicId ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || crypto.randomBytes(16).toString('hex')
const filename = `${safePublicId}.jpg`
if (getStorageDriver() === 's3') {
const objectKey = path.posix.join(visibility, folder.replace(/\\/g, '/'), filename)
const { putObject } = await import('./objectStorage')
await putObject(objectKey, buffer, 'image/jpeg')
// Keep the historical public URL shape so existing clients and resolveStoredFilePath continue to work via API proxy.
return `${getApiBase()}/storage/${folder}/${filename}`
}
const storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root')
}
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
+16 -21
View File
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { sharedRateLimitStore } from './redisRateLimitStore'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
@@ -50,15 +50,18 @@ function getAuthenticatedActorKey(req: Request): string | null {
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
function withStore(options: Parameters<typeof rateLimit>[0]) {
return rateLimit({
...options,
store: sharedRateLimitStore,
})
}
export const authLimiter = withStore({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
@@ -68,9 +71,8 @@ export const authLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
export const apiLimiter = withStore({
windowMs: 60 * 1000,
max: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
@@ -85,8 +87,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
export const publicLimiter = withStore({
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
@@ -96,9 +97,7 @@ export const publicLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Dedicated limiter for public payment/subscription webhooks. Provider retries
// still fit under this limit, but spray-and-pray signature attempts do not.
export const webhookLimiter = rateLimit({
export const webhookLimiter = withStore({
windowMs: 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
@@ -108,8 +107,7 @@ export const webhookLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
export const adminLimiter = withStore({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-7',
@@ -122,10 +120,7 @@ export const adminLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
export const actorLimiter = withStore({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
@@ -0,0 +1,80 @@
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
import { redis } from '../lib/redis'
/**
* Redis-backed store for express-rate-limit.
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
*/
export class RedisRateLimitStore implements Store {
prefix: string
windowMs = 60_000
#local = new Map<string, { totalHits: number; resetTime: Date }>()
constructor(prefix = 'rl:') {
this.prefix = prefix
}
init(options: Options): void {
this.windowMs = options.windowMs
}
private useMemory() {
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
if (this.useMemory()) {
const hit = this.#local.get(key)
if (!hit) return undefined
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
}
const redisKey = `${this.prefix}${key}`
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
if (count == null) return undefined
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
return { totalHits: Number(count), resetTime }
}
async increment(key: string): Promise<IncrementResponse> {
if (this.useMemory()) {
const now = Date.now()
const existing = this.#local.get(key)
if (!existing || existing.resetTime.getTime() <= now) {
const resetTime = new Date(now + this.windowMs)
this.#local.set(key, { totalHits: 1, resetTime })
return { totalHits: 1, resetTime }
}
existing.totalHits += 1
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
}
const redisKey = `${this.prefix}${key}`
const totalHits = await redis.incr(redisKey)
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
const ttl = await redis.pttl(redisKey)
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
return { totalHits, resetTime }
}
async decrement(key: string): Promise<void> {
if (this.useMemory()) {
const existing = this.#local.get(key)
if (existing && existing.totalHits > 0) existing.totalHits -= 1
return
}
const redisKey = `${this.prefix}${key}`
const value = await redis.decr(redisKey)
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
}
async resetKey(key: string): Promise<void> {
if (this.useMemory()) {
this.#local.delete(key)
return
}
await redis.del(`${this.prefix}${key}`)
}
}
export const sharedRateLimitStore = new RedisRateLimitStore('rl:api:')
@@ -165,7 +165,21 @@ describe('requireAdminRole middleware', () => {
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a 2FA-verified admin session until the session ends', () => {
it('allows a recently 2FA-verified admin session', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose 2FA proof is stale', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
@@ -175,8 +189,13 @@ describe('requireFreshAdmin2FA middleware', () => {
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification has expired; verify again to continue',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
@@ -86,6 +86,15 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
}
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
}
next()
}
@@ -9,13 +9,26 @@ describe('admin.presenter', () => {
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
passwordResetExpiresAt: new Date('2026-01-01T00:00:00.000Z'),
emailVerificationToken: 'verify-token',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
expect(
presentAdminSession(
{
id: 'admin_1',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
},
'jwt-token',
),
).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
+12 -1
View File
@@ -1,5 +1,16 @@
const ADMIN_SECRET_FIELDS = [
'passwordHash',
'totpSecret',
'passwordResetToken',
'passwordResetExpiresAt',
'emailVerificationToken',
] as const
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const { passwordHash, totpSecret, ...safe } = admin
const safe = { ...admin }
for (const field of ADMIN_SECRET_FIELDS) {
delete (safe as Record<string, unknown>)[field]
}
return safe
}
@@ -63,10 +63,7 @@ describe('admin.repo edge queries', () => {
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
OR: [
{ passwordResetToken: hashPublicAccessToken('reset-token') },
{ passwordResetToken: 'reset-token' },
],
passwordResetToken: hashPublicAccessToken('reset-token'),
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
+8 -1
View File
@@ -92,7 +92,7 @@ export function findAdminByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.adminUser.findFirst({
where: {
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
passwordResetToken: tokenHash,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -163,6 +163,13 @@ export async function applyCompanyUpdate(
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (typeof companyData.slug === 'string') {
companyData.slug = companyData.slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50) || 'company'
}
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
+7 -1
View File
@@ -122,7 +122,13 @@ const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-
export const adminCompanyUpdateSchema = z.object({
company: z.object({
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
name: z.string().min(1).optional(),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
.max(50)
.optional(),
email: z.string().email().optional(), phone: nullableString,
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
@@ -4,6 +4,7 @@ const prismaMock = vi.hoisted(() => ({
employee: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
}))
@@ -24,26 +25,32 @@ describe('auth.employee.repo query boundaries', () => {
})
it('looks up employee login emails case-insensitively and includes company context', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true },
take: 2,
})
})
it('only sends forgot-password emails to active employees', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
where: {
email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true,
},
take: 2,
})
})
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
it('requires unexpired hashed reset tokens for stored-token password reset lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
@@ -51,10 +58,7 @@ describe('auth.employee.repo query boundaries', () => {
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
OR: [
{ passwordResetToken: hashPublicAccessToken('reset_123') },
{ passwordResetToken: 'reset_123' },
],
passwordResetToken: hashPublicAccessToken('reset_123'),
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
},
})
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
employee: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}))
@@ -14,12 +14,13 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
describe('auth.employee.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
})
it('looks up employee login email case-insensitively', async () => {
await findEmployeeWithCompanyByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
expect(prisma.employee.findMany).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -27,13 +28,14 @@ describe('auth.employee.repo', () => {
},
},
include: { company: true },
take: 2,
})
})
it('looks up active employee reset email case-insensitively', async () => {
await findActiveEmployeeByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
expect(prisma.employee.findMany).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -41,6 +43,15 @@ describe('auth.employee.repo', () => {
},
isActive: true,
},
take: 2,
})
})
it('fails closed when multiple employees share an email', async () => {
vi.mocked(prisma.employee.findMany).mockResolvedValue([{ id: 'a' }, { id: 'b' }] as never)
await expect(findEmployeeWithCompanyByEmail('shared@example.com')).rejects.toMatchObject({
code: 'ambiguous_employee_email',
statusCode: 409,
})
})
})
@@ -14,8 +14,8 @@ export function findEmployeeById(id: string) {
})
}
export function findEmployeeWithCompanyByEmail(email: string) {
return prisma.employee.findFirst({
export async function findEmployeeWithCompanyByEmail(email: string) {
const matches = await prisma.employee.findMany({
where: {
email: {
equals: email,
@@ -23,11 +23,21 @@ export function findEmployeeWithCompanyByEmail(email: string) {
},
},
include: { company: true },
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Sign in with your company context or contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export function findActiveEmployeeByEmail(email: string) {
return prisma.employee.findFirst({
export async function findActiveEmployeeByEmail(email: string) {
const matches = await prisma.employee.findMany({
where: {
email: {
equals: email,
@@ -35,7 +45,17 @@ export function findActiveEmployeeByEmail(email: string) {
},
isActive: true,
},
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
@@ -56,7 +76,7 @@ export function findEmployeeByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: {
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
passwordResetToken: tokenHash,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -83,7 +103,7 @@ export function setEmailVerificationToken(id: string, tokenHash: string) {
export function findEmployeeByVerificationToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: { OR: [{ emailVerificationToken: tokenHash }, { emailVerificationToken: token }] },
where: { emailVerificationToken: tokenHash },
include: { company: true },
})
}
@@ -4,6 +4,7 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import { getIdempotentResult, setIdempotentResult } from '../../lib/idempotencyStore'
import * as service from './carplace.service'
import {
carplaceQuoteSchema,
@@ -23,13 +24,6 @@ import {
const router = Router()
router.use(optionalRenterAuth)
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
function cleanupIdempotencyCache(now = Date.now()) {
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
}
router.get('/home', async (_req, res, next) => {
try {
const [cities, offers, companies, search] = await Promise.all([
@@ -81,19 +75,18 @@ router.post('/quotes', async (req, res, next) => {
router.post('/reservations', async (req, res, next) => {
try {
cleanupIdempotencyCache()
const body = parseBody(carplaceReservationSchema, req)
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
if (key) {
const cached = idempotencyCache.get(key)
if (cached && cached.expiresAt > Date.now()) {
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
return ok(res, cached.result)
const cached = await getIdempotentResult('carplace:reservations', key, fingerprint)
if (cached.kind === 'conflict') {
return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
}
if (cached.kind === 'hit') return ok(res, cached.result)
}
const result = await service.createCarplaceReservation(body)
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
if (key) await setIdempotentResult('carplace:reservations', key, fingerprint, result)
created(res, result)
} catch (error) {
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
@@ -75,11 +75,13 @@ describe('payment.service', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z'))
delete process.env.API_URL
process.env.DASHBOARD_URL = 'https://app.example'
})
afterEach(() => {
vi.useRealTimers()
delete process.env.API_URL
delete process.env.DASHBOARD_URL
})
it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => {
@@ -4,6 +4,7 @@ import * as paypal from '../../services/paypalService'
import * as stripe from '../../services/stripeService'
import * as repo from './payment.repo'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
export function listByCompany(companyId: string) {
return repo.findByCompany(companyId)
@@ -120,6 +121,9 @@ export async function initCharge(reservationId: string, companyId: string, body:
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
}
assertAllowedPaymentRedirect(body.successUrl)
assertAllowedPaymentRedirect(body.failureUrl)
const amount = balanceDue
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
const orderId = `${reservationId}-${body.type}-${Date.now()}`
@@ -36,4 +36,20 @@ describe('serializeReservationForDashboard', () => {
expect(result.customer?.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/customer-1/license-image')
})
it('omits reviewToken from serialized dashboard payloads (S11)', () => {
const result = serializeReservationForDashboard({
id: 'reservation-2',
status: 'COMPLETED',
source: 'DASHBOARD',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'PAID',
extras: {},
reviewToken: 'secret-review-token',
customer: null,
} as any)
expect((result as any).reviewToken).toBeUndefined()
})
})
@@ -159,8 +159,11 @@ export function serializeReservationForDashboard<T extends {
}
: reservation.customer
// S11: never expose reviewToken on API responses (capability URL secret)
const { reviewToken: _reviewToken, ...safeReservation } = reservation as T & { reviewToken?: unknown }
return {
...reservation,
...safeReservation,
...(customer !== undefined ? { customer } : {}),
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
+11 -4
View File
@@ -4,6 +4,13 @@ import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './review.repo'
import * as reservationRepo from '../reservations/reservation.repo'
/** Strip capability secrets (reviewToken) from API-facing review payloads. */
function presentReview<T extends { reservation?: { reviewToken?: string | null } | null }>(review: T) {
if (!review.reservation) return review
const { reviewToken: _t, ...reservation } = review.reservation
return { ...review, reservation }
}
export async function listReviews(
companyId: string,
query: { rating?: number; page: number; pageSize: number },
@@ -14,7 +21,7 @@ export async function listReviews(
const [reviews, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
return {
data: reviews,
data: reviews.map(presentReview),
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
}
}
@@ -22,17 +29,17 @@ export async function listReviews(
export async function getReview(id: string, companyId: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return review
return presentReview(review)
}
export async function replyToReview(id: string, companyId: string, companyReply: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return repo.updateById(id, {
return presentReview(await repo.updateById(id, {
companyReply,
companyRepliedAt: new Date(),
})
}))
}
export async function getStats(companyId: string) {
+26 -2
View File
@@ -156,9 +156,19 @@ export async function createReservationPublicAccess(reservationId: string, token
})
}
export async function findReservationPublicAccess(reservationId: string, tokenHash: string) {
export async function findReservationPublicAccess(
reservationId: string,
tokenHash: string,
options: { requireUnused?: boolean } = {},
) {
return (prisma as any).reservationPublicAccess.findFirst({
where: { reservationId, tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
where: {
reservationId,
tokenHash,
revokedAt: null,
expiresAt: { gt: new Date() },
...(options.requireUnused ? { usedAt: null } : {}),
},
})
}
@@ -168,3 +178,17 @@ export async function markReservationPublicAccessUsed(id: string) {
data: { usedAt: new Date() },
})
}
/** Atomically burn a public access row. Returns false if already used/revoked/expired. */
export async function consumeReservationPublicAccess(id: string): Promise<boolean> {
const result = await (prisma as any).reservationPublicAccess.updateMany({
where: {
id,
usedAt: null,
revokedAt: null,
expiresAt: { gt: new Date() },
},
data: { usedAt: new Date() },
})
return result.count === 1
}
@@ -40,6 +40,7 @@ vi.mock('./site.repo', () => ({
createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(),
consumeReservationPublicAccess: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
@@ -88,6 +89,7 @@ describe('site.service public booking/payment boundaries', () => {
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never)
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never)
+26 -39
View File
@@ -10,40 +10,15 @@ import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
import { presentBrand, presentPublicBooking } from './site.presenter'
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
let parsed: URL
try {
parsed = new URL(urlValue)
} catch {
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
}
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
}
const allowedHosts = new Set<string>()
if (process.env.NODE_ENV !== 'production') {
allowedHosts.add('localhost:3000')
allowedHosts.add('localhost:4000')
allowedHosts.add('127.0.0.1:3000')
allowedHosts.add('127.0.0.1:4000')
}
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
if (!value) continue
try { allowedHosts.add(new URL(value).host) } catch {}
}
function assertCompanyPaymentRedirect(urlValue: string, company: any) {
const brand = company.brand as any
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
}
if (!allowedHosts.has(parsed.host)) {
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
}
assertAllowedPaymentRedirect(urlValue, {
customDomain: brand?.customDomain,
customDomainVerified: brand?.customDomainVerified,
subdomain: brand?.subdomain,
})
}
@@ -243,16 +218,28 @@ export async function createBooking(slug: string, body: {
}
}
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
async function assertPublicBookingAccess(
reservationId: string,
token: string | undefined,
options: { consume?: boolean } = {},
) {
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
const access = await repo.findReservationPublicAccess(
reservationId,
hashPublicAccessToken(token),
{ requireUnused: Boolean(options.consume) },
)
if (!access) throw new AppError('Booking not found', 404, 'not_found')
await repo.markReservationPublicAccessUsed(access.id)
if (options.consume) {
const burned = await repo.consumeReservationPublicAccess(access.id)
if (!burned) throw new AppError('Booking not found', 404, 'not_found')
}
}
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
const company = await repo.findCompanyBySlug(slug)
await assertPublicBookingAccess(reservationId, accessToken)
// Reads do not burn the token (S12); payment init consumes it once.
await assertPublicBookingAccess(reservationId, accessToken, { consume: false })
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
}
@@ -261,7 +248,7 @@ export async function initPayment(slug: string, reservationId: string, body: {
}) {
const company = await repo.findCompanyBySlug(slug)
assertPublicBookingCompanyAllowed(company)
await assertPublicBookingAccess(reservationId, body.accessToken)
await assertPublicBookingAccess(reservationId, body.accessToken, { consume: true })
const reservation = await repo.findReservationForPayment(reservationId, company.id)
if (reservation.paymentStatus === 'PAID') {
@@ -279,8 +266,8 @@ export async function initPayment(slug: string, reservationId: string, body: {
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
}
assertAllowedPaymentRedirect(body.successUrl, company)
assertAllowedPaymentRedirect(body.failureUrl, company)
assertCompanyPaymentRedirect(body.successUrl, company)
assertCompanyPaymentRedirect(body.failureUrl, company)
const currency = body.currency ?? 'MAD'
const amount = reservation.totalAmount
+10
View File
@@ -28,6 +28,7 @@ vi.mock('./site.repo', () => ({
createReservationPublicAccess: vi.fn(),
findReservationPublicAccess: vi.fn(),
markReservationPublicAccessUsed: vi.fn(),
consumeReservationPublicAccess: vi.fn(),
createRentalPayment: vi.fn(),
findPaymentByPaypalOrderId: vi.fn(),
capturePaypalPayment: vi.fn(),
@@ -110,6 +111,7 @@ beforeEach(() => {
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
})
// ────────────────────────────────────────────────────────────────────────────
@@ -296,5 +298,13 @@ describe('initPayment — payment guard paths', () => {
const result = await initPayment(SLUG, 'r-1', payBody)
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
expect(repo.consumeReservationPublicAccess).toHaveBeenCalled()
})
it('rejects payment when public access token was already consumed (S12)', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue(null as never)
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'not_found' })
})
})
@@ -82,11 +82,13 @@ describe('subscription.service operational edges', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
process.env.API_URL = 'https://api.example.test'
process.env.DASHBOARD_URL = 'https://app.example.test'
})
afterEach(() => {
vi.useRealTimers()
delete process.env.API_URL
delete process.env.DASHBOARD_URL
})
it('builds plans from pricing config rows when platform overrides exist', async () => {
@@ -7,6 +7,7 @@ import * as stripe from '../../services/stripeService'
import * as repo from './subscription.repo'
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
import {
createCanonicalStripeCheckoutInvoice,
finalizeCanonicalOnlinePayment,
@@ -279,6 +280,9 @@ export async function checkout(companyId: string, body: {
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
assertAllowedPaymentRedirect(body.successUrl)
assertAllowedPaymentRedirect(body.failureUrl)
const orderId = `sub-${companyId}-${Date.now()}`
const description = `${body.plan} plan — ${body.billingPeriod}`
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
@@ -1,4 +1,4 @@
import { PLAN_FEATURES } from '@rentaldrivego/types'
import { PLAN_FEATURES, PLAN_ENTITLEMENTS, getVehicleLimit } from '@rentaldrivego/types'
import { uploadImage } from '../../lib/storage'
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
@@ -351,12 +351,6 @@ const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV',
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
STARTER: 25,
GROWTH: 75,
PRO: 150,
ENTERPRISE: null,
}
function listTextVariants(value: string) {
const lower = value.toLowerCase()
@@ -390,10 +384,14 @@ async function getVehicleLimitForCompany(companyId: string) {
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
if (persistedLimit !== undefined) return persistedLimit
if (Object.prototype.hasOwnProperty.call(PLAN_ENTITLEMENTS, plan)) {
return getVehicleLimit(plan)
}
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
if (fallbackLimit !== undefined) return fallbackLimit
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
return null
}
async function assertCanAddActiveFleetVehicle(companyId: string) {
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it } from 'vitest'
import { AppError } from '../http/errors'
import { assertAllowedPaymentRedirect } from './paymentRedirects'
describe('assertAllowedPaymentRedirect', () => {
const previous = {
NODE_ENV: process.env.NODE_ENV,
DASHBOARD_URL: process.env.DASHBOARD_URL,
}
afterEach(() => {
process.env.NODE_ENV = previous.NODE_ENV
process.env.DASHBOARD_URL = previous.DASHBOARD_URL
})
it('allows configured first-party hosts', () => {
process.env.NODE_ENV = 'production'
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
expect(() => assertAllowedPaymentRedirect('https://app.rentaldrivego.test/billing/ok')).not.toThrow()
})
it('rejects unknown hosts', () => {
process.env.NODE_ENV = 'production'
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
expect(() => assertAllowedPaymentRedirect('https://evil.example/phish')).toThrow(AppError)
})
})
+74
View File
@@ -0,0 +1,74 @@
import { AppError } from '../http/errors'
/**
* Allowlist payment provider return URLs to first-party hosts only.
* Prevents authenticated checkout from being used as an open-redirect / phishing vector.
*/
export function assertAllowedPaymentRedirect(
urlValue: string,
options: {
customDomain?: string | null
customDomainVerified?: boolean | null
subdomain?: string | null
} = {},
) {
let parsed: URL
try {
parsed = new URL(urlValue)
} catch {
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
}
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
}
const allowedHosts = new Set<string>()
if (process.env.NODE_ENV !== 'production') {
for (const host of [
'localhost:3000',
'localhost:3001',
'localhost:3002',
'localhost:3004',
'localhost:4000',
'127.0.0.1:3000',
'127.0.0.1:3001',
'127.0.0.1:3002',
'127.0.0.1:3004',
'127.0.0.1:4000',
]) {
allowedHosts.add(host)
}
}
for (const value of [
process.env.CARPLACE_URL,
process.env.DASHBOARD_URL,
process.env.HOMEPAGE_URL,
process.env.ADMIN_URL,
process.env.API_URL,
process.env.NEXT_PUBLIC_CARPLACE_URL,
process.env.NEXT_PUBLIC_DASHBOARD_URL,
process.env.NEXT_PUBLIC_HOMEPAGE_URL,
process.env.NEXT_PUBLIC_WEBSITE_URL,
process.env.SITE_ORIGIN,
]) {
if (!value) continue
try {
allowedHosts.add(new URL(value).host)
} catch {
/* ignore invalid env */
}
}
if (options.customDomain && options.customDomainVerified) {
allowedHosts.add(options.customDomain)
}
if (options.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
allowedHosts.add(`${options.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
}
if (!allowedHosts.has(parsed.host)) {
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Allow only same-app relative paths for post-login redirects.
* Rejects protocol-relative URLs (//evil), absolute URLs, and backslash tricks.
*/
export function resolveSafeAppRedirect(candidate: string | null | undefined, fallback: string): string {
const value = (candidate ?? '').trim()
if (!value) return fallback
if (!value.startsWith('/')) return fallback
if (value.startsWith('//') || value.startsWith('/\\')) return fallback
if (value.includes('://')) return fallback
if (/[\r\n\\]/.test(value)) return fallback
return value
}
+10 -133
View File
@@ -1,136 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { ContainerFeatureDisabledError, buildComposeYaml, listCompanyContainers, provisionCompanyContainer } from './containerService'
const execFileMock = vi.fn()
const fsState = new Map<string, string>()
vi.mock('child_process', () => ({
execFile: (file: string, args: string[], cb: (error: any, stdout: string, stderr: string) => void) => {
const result = execFileMock(file, args) as { stdout?: string; stderr?: string } | undefined
cb(null, result?.stdout ?? '', result?.stderr ?? '')
},
}))
vi.mock('fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn(async (file: string) => {
if (!fsState.has(file)) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}),
readFile: vi.fn(async (file: string) => fsState.get(file) ?? '{}'),
writeFile: vi.fn(async (file: string, contents: string) => { fsState.set(file, contents) }),
}))
vi.mock('../lib/prisma', () => ({
prisma: {
companyContainer: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findUniqueOrThrow: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
delete: vi.fn(),
},
},
}))
import * as fs from 'fs/promises'
import { prisma } from '../lib/prisma'
import {
createCompanyContainer,
getContainerLogs,
restartCompanyContainer,
startCompanyContainer,
stopCompanyContainer,
syncContainerStatuses,
} from './containerService'
describe('containerService', () => {
beforeEach(() => {
vi.clearAllMocks()
fsState.clear()
fsState.set('/opt/companies/docker-compose.companies.yml', 'services: {}')
fsState.set('/opt/companies/companies-services.json', '{}')
})
afterEach(() => {
vi.resetModules()
})
it('allocates the first configured port, writes compose service metadata, starts the service, and stores docker id', async () => {
vi.mocked(prisma.companyContainer.findFirst).mockResolvedValue(null as never)
vi.mocked(prisma.companyContainer.create).mockResolvedValue({ id: 'container_row_1' } as never)
execFileMock.mockImplementation((file: string, args: string[]) => {
// The child_process mock calls back with empty stdout. This spy records the command shape.
return { file, args }
})
await createCompanyContainer({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars' })
expect(prisma.companyContainer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
companyId: 'company_1',
containerName: 'rdg-company-atlas-cars',
status: 'CREATING',
port: 5100,
}),
})
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/companies-services.json',
expect.stringContaining('"atlas-cars"'),
'utf8',
)
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/docker-compose.companies.yml',
expect.stringContaining('container_name: "rdg-company-atlas-cars"'),
'utf8',
)
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['version'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({
where: { id: 'container_row_1' },
data: { dockerId: null, status: 'RUNNING' },
describe('containerService (disabled)', () => {
it('fails closed for all orchestration entry points', async () => {
await expect(listCompanyContainers()).rejects.toBeInstanceOf(ContainerFeatureDisabledError)
await expect(provisionCompanyContainer('company_1')).rejects.toMatchObject({
code: 'container_feature_disabled',
statusCode: 501,
})
expect(() => buildComposeYaml([])).toThrow(ContainerFeatureDisabledError)
})
it('starts, stops, and restarts an existing company service with status transitions', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({
companyId: 'company_1',
company: { slug: 'atlas-cars' },
} as never)
await startCompanyContainer('company_1')
await stopCompanyContainer('company_1')
await restartCompanyContainer('company_1')
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'stop', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'restart', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RESTARTING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RUNNING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'STOPPED', errorMessage: null } })
})
it('returns an empty log string when docker log retrieval fails', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({ company: { slug: 'atlas-cars' } } as never)
execFileMock.mockImplementationOnce(() => { throw new Error('docker unavailable') })
await expect(getContainerLogs('company_1', 50)).resolves.toBe('')
})
it('loads container records and leaves stored statuses unchanged when docker status sync cannot complete', async () => {
vi.mocked(prisma.companyContainer.findMany).mockResolvedValue([
{ id: 'row_1', status: 'STOPPED', company: { slug: 'atlas-cars' } },
{ id: 'row_2', status: 'RUNNING', company: { slug: 'sahara-rentals' } },
] as never)
execFileMock.mockImplementation(() => { throw new Error('Docker daemon is unavailable') })
await expect(syncContainerStatuses()).resolves.toBeUndefined()
expect(prisma.companyContainer.findMany).toHaveBeenCalledWith({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
expect(prisma.companyContainer.update).not.toHaveBeenCalled()
})})
})
+34 -445
View File
@@ -1,469 +1,58 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import * as fs from 'fs/promises'
import * as path from 'path'
import { prisma as _prisma } from '../lib/prisma'
/**
* Per-tenant Docker container orchestration is OUT OF PRODUCTION SCOPE.
* This module is intentionally disabled so the business API never talks to
* a Docker socket or generates Compose YAML from tenant input.
*
* See docs/ADR-001-disable-per-tenant-containers.md
*/
const db = _prisma as any
export class ContainerFeatureDisabledError extends Error {
statusCode = 501
code = 'container_feature_disabled'
const execFileAsync = promisify(execFile)
let composeCommand: 'docker-compose' | 'docker' | null = null
// ── Config ────────────────────────────────────────────────────────────────────
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
// ── Types ─────────────────────────────────────────────────────────────────────
interface ServiceDef {
companyId: string
slug: string
image: string
port: number
}
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
// ── Internal helpers ──────────────────────────────────────────────────────────
function serviceName(slug: string) {
return `company-${slug}`
}
function containerName(slug: string) {
return `rdg-company-${slug}`
}
async function ensureDir(): Promise<void> {
await fs.mkdir(COMPOSE_DIR, { recursive: true })
}
async function readServices(): Promise<ServicesMap> {
try {
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
constructor(message = 'Per-tenant container management is disabled and out of production scope') {
super(message)
this.name = 'ContainerFeatureDisabledError'
}
}
async function writeServices(services: ServicesMap): Promise<void> {
await ensureDir()
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
function disabled(): never {
throw new ContainerFeatureDisabledError()
}
async function composeFilesExist(): Promise<boolean> {
try {
await fs.access(COMPOSE_FILE)
await fs.access(SERVICES_FILE)
return true
} catch {
return false
}
export async function provisionCompanyContainer(_companyId: string): Promise<never> {
return disabled()
}
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await db.companyContainer.findMany({
include: { company: { select: { slug: true } } },
})
return Object.fromEntries(
records.map((record: any) => [
serviceName(record.company.slug),
{
companyId: record.companyId,
slug: record.company.slug,
image: record.image,
port: record.port,
},
]),
)
export async function startCompanyContainer(_companyId: string): Promise<never> {
return disabled()
}
async function ensureComposeFiles(): Promise<void> {
if (await composeFilesExist()) return
const services = await rebuildServicesFromDatabase()
await writeServices(services)
export async function stopCompanyContainer(_companyId: string): Promise<never> {
return disabled()
}
function buildComposeYaml(services: ServicesMap): string {
const lines: string[] = ['services:']
for (const [name, svc] of Object.entries(services)) {
lines.push(` ${name}:`)
lines.push(` image: "${svc.image}"`)
lines.push(` container_name: "${containerName(svc.slug)}"`)
lines.push(` restart: unless-stopped`)
lines.push(` environment:`)
lines.push(` COMPANY_ID: "${svc.companyId}"`)
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
lines.push(` PORT: "3000"`)
lines.push(` NODE_ENV: "production"`)
lines.push(` ports:`)
lines.push(` - "${svc.port}:3000"`)
lines.push(` networks:`)
lines.push(` - ${CONTAINER_NETWORK}`)
lines.push(` labels:`)
lines.push(` rdg.managed: "true"`)
lines.push(` rdg.company.id: "${svc.companyId}"`)
lines.push(` rdg.company.slug: "${svc.slug}"`)
}
lines.push('')
lines.push('networks:')
lines.push(` ${CONTAINER_NETWORK}:`)
lines.push(` external: true`)
lines.push('')
return lines.join('\n')
export async function restartCompanyContainer(_companyId: string): Promise<never> {
return disabled()
}
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
if (composeCommand) return composeCommand
try {
await execFileAsync('docker-compose', ['version'])
composeCommand = 'docker-compose'
return composeCommand
} catch {
try {
await execFileAsync('docker', ['compose', 'version'])
composeCommand = 'docker'
return composeCommand
} catch (err) {
throw mapDockerError(err)
}
}
export async function removeCompanyContainer(_companyId: string): Promise<never> {
return disabled()
}
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
try {
await ensureComposeFiles()
const command = await resolveComposeCommand()
if (command === 'docker-compose') {
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
}
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
} catch (err) {
throw mapDockerError(err)
}
export async function getCompanyContainerLogs(_companyId: string, _tail = 100): Promise<never> {
return disabled()
}
function dockerUnavailableError(message: string, details?: string) {
const err = Object.assign(new Error(message), {
statusCode: 503,
code: 'docker_unavailable',
})
if (details) {
;(err as Error & { details?: string }).details = details
}
return err
export async function listCompanyContainers(): Promise<never> {
return disabled()
}
function mapDockerError(err: unknown) {
if (err && typeof err === 'object') {
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
const stderr = error.stderr?.trim()
if (error.code === 'ENOENT' && error.path === 'docker') {
return dockerUnavailableError(
'Docker CLI is not available to the API service.',
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
)
}
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes("docker: 'compose' is not a docker command.") ||
stderr?.includes("unknown shorthand flag: 'f' in -f")
) {
return dockerUnavailableError(
'Docker Compose is not available to the API service.',
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
)
}
if (
stderr?.includes('Cannot connect to the Docker daemon') ||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
stderr?.includes('error during connect')
) {
return dockerUnavailableError(
'Docker daemon is not reachable from the API service.',
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
)
}
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
if (typeof error.code === 'number' && error.code !== 0) {
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
}
}
return err
export async function provisionAllPending(): Promise<never> {
return disabled()
}
async function setContainerError(companyId: string, message: string): Promise<void> {
await db.companyContainer.update({
where: { companyId },
data: { status: 'ERROR', errorMessage: message },
}).catch(() => null)
}
async function runContainerAction(
companyId: string,
slug: string,
command: 'start' | 'stop' | 'restart',
successStatus: Exclude<ServiceStatus, 'ERROR'>,
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> {
if (preStatus) {
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
}
try {
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
const args = command === 'start'
? ['up', '-d', '--no-recreate', serviceName(slug)]
: [command, serviceName(slug)]
await compose(...args)
await db.companyContainer.update({
where: { companyId },
data: { status: successStatus, errorMessage: null },
})
} catch (err) {
const message = err instanceof Error ? err.message : `Failed to ${command} container`
await setContainerError(companyId, message)
throw err
}
}
async function allocatePort(): Promise<number> {
const last = await db.companyContainer.findFirst({
orderBy: { port: 'desc' },
select: { port: true },
})
const next = last ? last.port + 1 : PORT_RANGE_START
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
return next
}
async function getDockerServiceId(slug: string): Promise<string | null> {
try {
const name = serviceName(slug)
const { stdout } = await compose('ps', '--format', 'json', name)
const line = stdout.trim().split('\n')[0]
if (!line) return null
const info = JSON.parse(line)
return info.ID ?? info.Id ?? null
} catch {
return null
}
}
// Maps docker compose State strings to our DB enum
function mapDockerState(state: string): ServiceStatus {
switch (state.toLowerCase()) {
case 'running': return 'RUNNING'
case 'restarting': return 'RESTARTING'
case 'exited':
case 'created':
case 'paused': return 'STOPPED'
default: return 'ERROR'
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export async function createCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const port = await allocatePort()
const record = await db.companyContainer.create({
data: {
companyId: company.id,
containerName: containerName(company.slug),
status: 'CREATING',
port,
image: DASHBOARD_IMAGE,
},
})
try {
const services = await readServices()
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
await writeServices(services)
await compose('up', '-d', '--no-recreate', name)
const dockerId = await getDockerServiceId(company.slug)
await db.companyContainer.update({
where: { id: record.id },
data: { dockerId, status: 'RUNNING' },
})
} catch (err) {
await db.companyContainer.update({
where: { id: record.id },
data: {
status: 'ERROR',
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
},
})
throw err
}
}
export async function startCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
}
export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
}
export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
}
export async function removeCompanyContainer(companyId: string): Promise<void> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
const name = serviceName(record.company.slug)
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
try {
await compose('stop', name)
} catch { /* already stopped */ }
try {
await compose('rm', '-f', name)
} catch { /* already removed */ }
const services = await readServices()
delete services[name]
await writeServices(services)
await db.companyContainer.delete({ where: { companyId } })
}
export async function redeployCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) {
try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ }
await db.companyContainer.delete({ where: { companyId: company.id } })
}
// Remove from compose file too, then recreate
const services = await readServices()
delete services[name]
await writeServices(services)
await createCompanyContainer(company)
}
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
const record = await db.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
try {
const { stdout } = await compose(
'logs',
'--no-log-prefix',
`--tail=${tail}`,
serviceName(record.company.slug),
)
return stdout
} catch {
return ''
}
}
export async function syncContainerStatuses(): Promise<void> {
const records = await db.companyContainer.findMany({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
try {
// Get all compose service states in one shot
const { stdout } = await compose('ps', '--format', 'json')
const rows = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => {
try { return JSON.parse(line) } catch { return null }
})
.filter(Boolean) as Array<{ Service: string; State: string }>
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
await Promise.allSettled(
records.map(async (rec: any) => {
const name = serviceName(rec.company.slug)
const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) {
await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
}
}),
)
} catch {
// Docker daemon unreachable — leave statuses as-is
}
/** @deprecated Kept only so accidental imports fail closed. */
export function buildComposeYaml(_services: unknown): never {
return disabled()
}
+75 -8
View File
@@ -1,5 +1,6 @@
import { Resend } from 'resend'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
@@ -708,18 +709,55 @@ async function deliveryEmail(recipient: any) {
?? null
}
export async function processNotificationOutbox(limit = 50) {
export async function processNotificationOutbox(limit = 50, workerId = `worker:${process.pid}`) {
const leaseExpiredBefore = new Date(Date.now() - 2 * 60 * 1000)
const now = new Date()
const candidates = await prisma.notificationOutbox.findMany({
where: {
status: 'PENDING',
AND: [
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
],
},
orderBy: { createdAt: 'asc' },
take: Math.max(1, Math.min(limit, 200)),
select: { id: true },
})
const claimedIds: string[] = []
for (const candidate of candidates) {
const claimed = await prisma.notificationOutbox.updateMany({
where: {
id: candidate.id,
status: 'PENDING',
AND: [
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
],
},
data: {
lockedAt: now,
lockedBy: workerId,
attempts: { increment: 1 },
},
})
if (claimed.count === 1) claimedIds.push(candidate.id)
}
if (claimedIds.length === 0) return 0
const entries = await prisma.notificationOutbox.findMany({
where: { status: 'PENDING' },
where: { id: { in: claimedIds } },
include: {
notificationEvent: {
include: {
recipients: {
include: {
employee: { select: { email: true } },
renter: { select: { email: true } },
employee: { select: { id: true, email: true } },
renter: { select: { id: true, email: true } },
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
adminUser: { select: { email: true, isActive: true } },
adminUser: { select: { id: true, email: true, isActive: true } },
deliveries: true,
},
},
@@ -727,7 +765,6 @@ export async function processNotificationOutbox(limit = 50) {
},
},
orderBy: { createdAt: 'asc' },
take: Math.max(1, Math.min(limit, 200)),
})
let processed = 0
@@ -740,7 +777,10 @@ export async function processNotificationOutbox(limit = 50) {
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
})
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
await prisma.notificationOutbox.update({
where: { id: entry.id },
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
})
processed += 1
continue
}
@@ -756,6 +796,20 @@ export async function processNotificationOutbox(limit = 50) {
where: { id: delivery.id },
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
})
const userId = recipient.employeeId ?? recipient.renterId ?? recipient.adminUserId
if (userId) {
await redis.publish(
`notifications:${userId}`,
JSON.stringify({
id: event.id,
type: event.type,
title: event.title,
body: event.body,
data: event.data,
createdAt: event.createdAt,
}),
)
}
} else if (delivery.channel === 'EMAIL') {
const to = await deliveryEmail(recipient)
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
@@ -810,8 +864,21 @@ export async function processNotificationOutbox(limit = 50) {
},
})
if (remaining === 0) {
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
await prisma.notificationOutbox.update({
where: { id: entry.id },
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
})
processed += 1
} else {
await prisma.notificationOutbox.update({
where: { id: entry.id },
data: {
lockedAt: null,
lockedBy: null,
availableAt: new Date(Date.now() + 30_000),
failureReason: 'Pending deliveries remain',
},
})
}
}
return processed
+52 -6
View File
@@ -5,6 +5,7 @@ vi.mock('../lib/prisma', () => ({
employee: {
findFirst: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
},
company: {
findUniqueOrThrow: vi.fn(),
@@ -19,7 +20,8 @@ vi.mock('./notificationService', () => ({
import crypto from 'crypto'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
import { inviteEmployee } from './teamService'
import { hashPublicAccessToken } from '../security/publicAccessTokens'
import { inviteEmployee, listEmployees } from './teamService'
describe('teamService inviteEmployee', () => {
const originalDashboardUrl = process.env.DASHBOARD_URL
@@ -37,11 +39,19 @@ describe('teamService inviteEmployee', () => {
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any)
vi.mocked(prisma.employee.create).mockResolvedValue({
id: 'emp_1',
clerkUserId: 'local_member_uuid-123',
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
phone: null,
role: 'AGENT',
isActive: true,
preferredLanguage: 'en',
emailVerified: null,
passwordHash: null,
passwordResetToken: 'hashed',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
} as any)
})
@@ -51,20 +61,56 @@ describe('teamService inviteEmployee', () => {
vi.restoreAllMocks()
})
it('builds invite reset links under the dashboard base path', async () => {
await inviteEmployee('company_1', 'owner_1', {
it('stores hashed invite tokens and never returns secrets in the API payload', async () => {
const rawToken = Buffer.from('token-123').toString('hex')
const result = await inviteEmployee('company_1', 'owner_1', {
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
email: 'Aya@Example.com',
role: 'AGENT',
})
expect(prisma.employee.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
email: 'aya@example.com',
passwordResetToken: hashPublicAccessToken(rawToken),
}),
}),
)
expect(result.employee).not.toHaveProperty('passwordHash')
expect(result.employee).not.toHaveProperty('passwordResetToken')
expect(result.employee.invitationStatus).toBe('pending')
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'aya@example.com',
html: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
text: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
html: expect.stringContaining(`http://localhost:3000/dashboard/reset-password?token=${rawToken}`),
}),
)
})
it('lists team members without password hashes or reset tokens', async () => {
vi.mocked(prisma.employee.findMany).mockResolvedValue([
{
id: 'emp_1',
clerkUserId: 'c1',
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.com',
phone: null,
role: 'AGENT',
isActive: true,
preferredLanguage: 'en',
emailVerified: null,
passwordHash: '$2a$12$secret',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
},
] as any)
const members = await listEmployees('company_1')
expect(members[0]).not.toHaveProperty('passwordHash')
expect(members[0]).not.toHaveProperty('passwordResetToken')
expect(members[0]?.invitationStatus).toBe('accepted')
})
})
+65 -17
View File
@@ -3,6 +3,7 @@ import { EmployeeRole } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
import { coerceNotificationLocale } from './notificationLocalizationService'
import { hashPublicAccessToken } from '../security/publicAccessTokens'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
@@ -22,12 +23,47 @@ export interface TeamMember {
phone: string | null
role: EmployeeRole
isActive: boolean
preferredLanguage?: string
emailVerified?: Date | null
createdAt: Date
updatedAt: Date
lastActiveAt?: Date | null
invitationStatus?: 'accepted' | 'pending' | 'revoked'
}
function presentTeamMember(employee: {
id: string
clerkUserId: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
preferredLanguage?: string
emailVerified?: Date | null
passwordHash?: string | null
createdAt: Date
updatedAt: Date
}): TeamMember {
return {
id: employee.id,
clerkUserId: employee.clerkUserId,
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
phone: employee.phone,
role: employee.role,
isActive: employee.isActive,
preferredLanguage: employee.preferredLanguage,
emailVerified: employee.emailVerified ?? null,
createdAt: employee.createdAt,
updatedAt: employee.updatedAt,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
}
}
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
@@ -96,13 +132,24 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
const employees = await prisma.employee.findMany({
where: { companyId },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
select: {
id: true,
clerkUserId: true,
firstName: true,
lastName: true,
email: true,
phone: true,
role: true,
isActive: true,
preferredLanguage: true,
emailVerified: true,
passwordHash: true,
createdAt: true,
updatedAt: true,
},
})
return employees.map((employee: any) => ({
...employee,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
}))
return employees.map(presentTeamMember)
}
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
@@ -110,7 +157,8 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' })
}
const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } })
const email = payload.email.trim().toLowerCase()
const existing = await prisma.employee.findFirst({ where: { companyId, email: { equals: email, mode: 'insensitive' } } })
if (existing) {
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
@@ -120,6 +168,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
include: { brand: { select: { defaultLocale: true, displayName: true } } },
})
const rawToken = crypto.randomBytes(32).toString('hex')
const tokenHash = hashPublicAccessToken(rawToken)
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
@@ -129,11 +178,11 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
clerkUserId: `local_member_${crypto.randomUUID()}`,
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
email,
role: payload.role,
isActive: true,
preferredLanguage: locale,
passwordResetToken: rawToken,
passwordResetToken: tokenHash,
passwordResetExpiresAt: expiresAt,
},
})
@@ -151,18 +200,14 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
)
await sendTransactionalEmail({
to: payload.email,
to: email,
subject: message.subject,
html: message.html,
text: message.text,
})
return {
employee: {
...employee,
lastActiveAt: null,
invitationStatus: 'pending' as const,
},
employee: presentTeamMember(employee),
invitationId: employee.id,
invitedBy: inviterId,
}
@@ -176,7 +221,8 @@ export async function updateEmployeeRole(companyId: string, requesterId: string,
if (payload.role === 'OWNER') throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 })
if (target.id === requesterId) throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
return presentTeamMember(updated)
}
export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
@@ -184,14 +230,16 @@ export async function deactivateEmployee(companyId: string, requesterRole: Emplo
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
return presentTeamMember(updated)
}
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
return presentTeamMember(updated)
}
export async function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
+24 -1
View File
@@ -185,7 +185,7 @@ export const openApiDocument: JsonObject = {
'/health': {
get: {
tags: ['Health'],
summary: 'Health check',
summary: 'Liveness health check',
security: [],
servers: [{ url: '/' }],
responses: {
@@ -193,6 +193,29 @@ export const openApiDocument: JsonObject = {
},
},
},
'/ready': {
get: {
tags: ['Health'],
summary: 'Readiness probe (database, redis, storage)',
security: [],
servers: [{ url: '/' }],
responses: {
'200': { description: 'Ready' },
'503': { description: 'Not ready' },
},
},
},
'/metrics': {
get: {
tags: ['Health'],
summary: 'Prometheus-style ops metrics',
security: [],
servers: [{ url: '/' }],
responses: {
'200': { description: 'Prometheus text exposition format' },
},
},
},
// ════════════════════════════════════════════════════════════════
// AUTH — COMPANY
@@ -0,0 +1,146 @@
import request from 'supertest'
import { prisma } from '../../lib/prisma'
import { createApp } from '../../app'
import {
createCompanyWithEmployee,
createVehicle,
createCustomer,
createReservation,
createRentalPayment,
signEmployeeToken,
authHeader,
} from '../helpers/fixtures'
/**
* Phase 3 — cross-tenant negative suite.
* Pattern: Company A credentials must not read/mutate Company B resources (404, no leak).
*/
const app = createApp()
describe('Cross-tenant isolation (Phase 3)', () => {
let companyAId: string
let tokenA: string
let companyBId: string
let employeeBId: string
beforeAll(async () => {
const a = await createCompanyWithEmployee({ role: 'OWNER' })
const b = await createCompanyWithEmployee({ role: 'OWNER' })
companyAId = a.company.id
tokenA = signEmployeeToken(a.employee.id, companyAId, 'OWNER')
companyBId = b.company.id
employeeBId = b.employee.id
})
it('GET foreign vehicle → 404', async () => {
const foreign = await createVehicle(companyBId, { make: 'Foreign' })
const res = await request(app)
.get(`/api/v1/vehicles/${foreign.id}`)
.set(authHeader(tokenA))
expect(res.status).toBe(404)
expect(JSON.stringify(res.body)).not.toMatch(/Foreign/)
})
it('GET foreign vehicle maintenance → 404', async () => {
const foreign = await createVehicle(companyBId)
const res = await request(app)
.get(`/api/v1/vehicles/${foreign.id}/maintenance`)
.set(authHeader(tokenA))
expect(res.status).toBe(404)
})
it('GET foreign customer → 404', async () => {
const foreign = await createCustomer(companyBId, { firstName: 'SecretTenantB' })
const res = await request(app)
.get(`/api/v1/customers/${foreign.id}`)
.set(authHeader(tokenA))
expect(res.status).toBe(404)
expect(JSON.stringify(res.body)).not.toMatch(/SecretTenantB/)
})
it('GET foreign reservation → 404', async () => {
const vehicle = await createVehicle(companyBId)
const customer = await createCustomer(companyBId)
const foreign = await createReservation(companyBId, vehicle.id, customer.id)
const res = await request(app)
.get(`/api/v1/reservations/${foreign.id}`)
.set(authHeader(tokenA))
expect(res.status).toBe(404)
})
it('PATCH foreign customer → not successful', async () => {
const foreign = await createCustomer(companyBId)
const res = await request(app)
.patch(`/api/v1/customers/${foreign.id}`)
.set(authHeader(tokenA))
.send({ firstName: 'Hijacked' })
expect([404, 403, 405]).toContain(res.status)
expect(res.status).not.toBe(200)
})
it('GET foreign offer → 404', async () => {
const offer = await prisma.offer.create({
data: {
companyId: companyBId,
title: 'SecretOfferB',
type: 'PERCENTAGE',
discountValue: 10,
validFrom: new Date(),
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
isActive: true,
isPublic: true,
} as any,
})
const res = await request(app)
.get(`/api/v1/offers/${offer.id}`)
.set(authHeader(tokenA))
expect(res.status).toBe(404)
expect(JSON.stringify(res.body)).not.toMatch(/SecretOfferB/)
})
it('team list does not include foreign employees', async () => {
const res = await request(app)
.get('/api/v1/team')
.set(authHeader(tokenA))
expect(res.status).toBe(200)
const body = JSON.stringify(res.body)
expect(body).not.toContain(employeeBId)
})
it('cannot deactivate foreign team member', async () => {
const res = await request(app)
.post(`/api/v1/team/${employeeBId}/deactivate`)
.set(authHeader(tokenA))
expect([404, 403]).toContain(res.status)
})
it('GET payments for foreign reservation → 404 or empty without leak', async () => {
const vehicle = await createVehicle(companyBId)
const customer = await createCustomer(companyBId, { firstName: 'PayeeSecret' })
const reservation = await createReservation(companyBId, vehicle.id, customer.id)
await createRentalPayment(companyBId, reservation.id, { amount: 9999 })
const res = await request(app)
.get(`/api/v1/payments/reservations/${reservation.id}`)
.set(authHeader(tokenA))
expect([404, 200]).toContain(res.status)
if (res.status === 200) {
const payments = res.body.data ?? res.body
expect(Array.isArray(payments) ? payments.length : 0).toBe(0)
}
expect(JSON.stringify(res.body)).not.toMatch(/PayeeSecret|9999/)
})
it('reservation JSON never includes reviewToken for own company either (S11)', async () => {
const vehicle = await createVehicle(companyAId)
const customer = await createCustomer(companyAId)
const reservation = await createReservation(companyAId, vehicle.id, customer.id, {
reviewToken: 'should-not-leak-phase3',
})
const res = await request(app)
.get(`/api/v1/reservations/${reservation.id}`)
.set(authHeader(tokenA))
expect(res.status).toBe(200)
expect(JSON.stringify(res.body)).not.toMatch(/should-not-leak-phase3/)
expect(res.body.data?.reviewToken).toBeUndefined()
})
})
+57
View File
@@ -0,0 +1,57 @@
import http from 'node:http'
import { assertStorageConfiguration } from '../lib/storage'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { renderPrometheusText, setGauge } from '../lib/opsMetrics'
import { startOutboxWorker, startScheduledJobs } from './jobs'
assertStorageConfiguration()
const workerId = process.env.WORKER_ID ?? String(process.pid)
const metricsPort = Number(process.env.WORKER_METRICS_PORT ?? 0)
console.log(`[worker] starting jobs worker id=${workerId}`)
startOutboxWorker()
startScheduledJobs()
if (Number.isFinite(metricsPort) && metricsPort > 0) {
const server = http.createServer(async (req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok', role: 'worker', workerId }))
return
}
if (req.url === '/metrics') {
try {
const pending = await prisma.notificationOutbox.count({ where: { status: 'PENDING' } })
setGauge('notification_outbox_pending', pending)
} catch {
/* leave previous gauge */
}
const body = renderPrometheusText()
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' })
res.end(body)
return
}
res.writeHead(404)
res.end()
})
server.listen(metricsPort, () => {
console.log(`[worker] metrics listening on :${metricsPort}`)
})
}
async function shutdown(signal: string) {
console.log(`[worker] ${signal} received, shutting down`)
try {
await redis.quit()
} catch {
redis.disconnect()
}
await prisma.$disconnect()
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
+230
View File
@@ -0,0 +1,230 @@
import cron from 'node-cron'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { processNotificationOutbox, sendNotification } from '../services/notificationService'
import { observeOutboxProcessed } from '../lib/opsMetrics'
import {
runTrialExpirationJob,
runPeriodEndCancellationJob,
} from '../modules/subscriptions/subscription.service'
import { runCollectionsWorker } from '../modules/subscriptions/subscription.collections.service'
const WORKER_ID = process.env.WORKER_ID ?? `jobs:${process.pid}`
const LEADER_KEY = 'rentaldrivego:jobs:leader'
const LEADER_TTL_SECONDS = 30
async function withLeaderLock<T>(fn: () => Promise<T>): Promise<T | null> {
const acquired = await redis.set(LEADER_KEY, WORKER_ID, 'EX', LEADER_TTL_SECONDS, 'NX')
if (acquired !== 'OK') {
const current = await redis.get(LEADER_KEY)
if (current !== WORKER_ID) return null
} else {
// refresh TTL periodically while holding
}
try {
return await fn()
} finally {
const current = await redis.get(LEADER_KEY)
if (current === WORKER_ID) await redis.del(LEADER_KEY)
}
}
async function renewLeaderIfOwned() {
const current = await redis.get(LEADER_KEY)
if (current === WORKER_ID) await redis.expire(LEADER_KEY, LEADER_TTL_SECONDS)
}
export async function runLicenseExpiryJob() {
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
for (const c of customers) {
if (!c.licenseExpiry) continue
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const expired = c.licenseExpiry <= new Date()
const expiring = !expired && daysLeft < 90
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
await prisma.customer.update({
where: { id: c.id },
data: {
licenseExpired: expired,
licenseExpiringSoon: expiring,
licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID',
},
})
}
}
}
export async function runTrialEndingRemindersJob() {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
})
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (!owner) continue
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
export async function runMaintenanceRemindersJob() {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: {
vehicle: {
include: {
company: {
include: {
employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 },
},
},
},
},
},
orderBy: { performedAt: 'desc' },
})
const latestByKey = new Map<string, (typeof allCandidates)[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
const reminderDate = now.toISOString().slice(0, 10)
await sendNotification({
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channels: ['IN_APP'],
sourceType: 'maintenance_log',
sourceId: log.id,
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
})
}
}
/** Outbox dispatch does its own DB leasing — safe across workers without Redis leader. */
export function startOutboxWorker() {
cron.schedule('* * * * *', async () => {
try {
const n = await processNotificationOutbox(50, WORKER_ID)
if (n > 0) {
observeOutboxProcessed(n)
console.log(`[notifications] outbox: ${n} events completed`)
}
} catch (err: any) {
console.error('[notifications] outbox worker failed:', err?.message ?? err)
}
})
}
/** Scheduled domain jobs — only one leader executes them. */
export function startScheduledJobs() {
setInterval(() => {
void renewLeaderIfOwned()
}, 10_000)
cron.schedule('0 8 * * *', async () => {
await withLeaderLock(async () => {
await runLicenseExpiryJob()
await runMaintenanceRemindersJob()
})
})
cron.schedule('0 * * * *', async () => {
await withLeaderLock(async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
})
cron.schedule('0 1 * * *', async () => {
await withLeaderLock(async () => {
const nPeriod = await runPeriodEndCancellationJob()
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
})
cron.schedule('*/15 * * * *', async () => {
await withLeaderLock(async () => {
const n = await runCollectionsWorker()
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
})
})
cron.schedule('0 9 * * *', async () => {
await withLeaderLock(async () => {
await runTrialEndingRemindersJob()
})
})
}