From a010ad6811c6942c12389896e72a573969a11819 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 19 Jul 2026 22:22:33 -0400 Subject: [PATCH] apply security fix --- apps/SECURITY_HARDENING_APPLIED.md | 41 +++++++ apps/api/src/app.ts | 33 ++++-- apps/api/src/http/upload/index.ts | 50 +++++++- apps/api/src/lib/storage.ts | 1 + apps/api/src/middleware/csrf.ts | 88 ++++++++++++++ apps/api/src/middleware/forwardedHeaders.ts | 26 ++++ apps/api/src/middleware/rateLimiter.ts | 11 ++ .../src/middleware/requireAdminAuth.test.ts | 19 ++- apps/api/src/middleware/requireAdminAuth.ts | 27 +++-- .../api/src/middleware/requireSubscription.ts | 111 +++++++++++++----- apps/api/src/modules/admin/admin.repo.ts | 8 +- apps/api/src/modules/admin/admin.routes.ts | 4 +- apps/api/src/modules/admin/admin.service.ts | 3 +- .../src/modules/analytics/analytics.routes.ts | 10 +- .../src/modules/auth/auth.account.service.ts | 3 +- .../src/modules/auth/auth.employee.repo.ts | 11 +- .../src/modules/auth/auth.employee.service.ts | 3 +- .../api/src/modules/billing/billing.routes.ts | 6 +- .../api/src/modules/carplace/carplace.repo.ts | 4 +- .../src/modules/carplace/carplace.schemas.ts | 6 +- .../src/modules/companies/company.routes.ts | 44 +++---- .../modules/complaints/complaint.routes.ts | 11 +- .../src/modules/customers/customer.routes.ts | 20 ++-- .../notifications/notification.routes.ts | 19 +-- apps/api/src/modules/offers/offer.routes.ts | 14 +-- apps/api/src/modules/payments/payment.repo.ts | 11 ++ .../src/modules/payments/payment.routes.ts | 16 +-- .../src/modules/payments/payment.service.ts | 42 ++++++- .../reservations/reservation.routes.ts | 29 ++--- apps/api/src/modules/reviews/review.routes.ts | 9 +- apps/api/src/modules/site/site.repo.ts | 6 +- apps/api/src/modules/site/site.service.ts | 13 ++ .../subscriptions/subscription.policy.ts | 4 + .../subscriptions/subscription.routes.ts | 17 +-- apps/api/src/modules/team/team.routes.ts | 18 +-- .../src/modules/vehicles/vehicle.routes.ts | 32 ++--- apps/api/src/security/webhookIdempotency.ts | 24 +++- apps/api/src/services/amanpayService.ts | 6 +- .../src/app/api/v1/[...path]/route.ts | 17 ++- apps/dashboard/src/middleware.test.ts | 6 +- apps/dashboard/src/middleware.ts | 27 ++--- 41 files changed, 626 insertions(+), 224 deletions(-) create mode 100644 apps/SECURITY_HARDENING_APPLIED.md create mode 100644 apps/api/src/middleware/csrf.ts create mode 100644 apps/api/src/middleware/forwardedHeaders.ts diff --git a/apps/SECURITY_HARDENING_APPLIED.md b/apps/SECURITY_HARDENING_APPLIED.md new file mode 100644 index 0000000..b58954a --- /dev/null +++ b/apps/SECURITY_HARDENING_APPLIED.md @@ -0,0 +1,41 @@ +# Security Hardening Applied + +This bundle applies the highest-risk items from the hardened security fix plan to the uploaded apps source. + +## Applied changes + +- Added explicit subscription read/write/full middleware and moved sensitive mutations to write/full checks. +- Hardened backend route authorization for reservations, customers, vehicles, analytics, payments, reviews, complaints, team, companies, offers, notifications, billing, and subscription actions. +- Blocked AGENT access to manager/owner actions including reservation lifecycle transitions, analytics, payments, review replies/reminders, team lists/stats, license image reads, license validation, and complaint mutations. +- Disabled public Swagger/OpenAPI exposure in production unless `ENABLE_PUBLIC_API_DOCS=true` is explicitly set. +- Added CSRF-style Origin/Referer enforcement for cookie-authenticated browser mutations. +- Added forwarding-header sanitization middleware and stripped spoofable forwarding headers from the dashboard API proxy. +- Changed dashboard redirects to use the configured canonical dashboard URL instead of request-supplied forwarded host headers. +- Blocked anonymous `/storage` access to reservation photo paths and classified reservation uploads as private storage. +- Tightened image upload limits, file count limits, magic-byte validation, extension consistency, and basic image dimension limits. +- Added a dedicated webhook rate limiter and raw body size limits. +- Hardened AmanPay webhook signature verification so invalid signature length returns false instead of throwing. +- Hardened webhook idempotency by rejecting missing provider event IDs and allowing stale PROCESSING records to retry after a timeout. +- Hardened PayPal capture by binding capture to company, reservation, local pending payment status, provider completion status, amount, and currency. +- Restricted public site/carplace booking flows to ACTIVE/TRIALING companies and AVAILABLE published vehicles. +- Capped public Carplace pagination and metadata size. +- Replaced rank-only admin role checks with explicit role allowlists so SUPPORT no longer implicitly outranks FINANCE. +- Required fresh admin 2FA before replacing an already-enabled admin TOTP setup. +- Started storing admin reset tokens and employee email verification tokens as hashes while preserving legacy raw-token lookup during migration. + +## Validation performed + +The sandbox did not include a usable installed workspace dependency tree, so full `npm test` / `npm run type-check` could not be executed from the extracted bundle. A TypeScript syntactic transpile check was run against all changed TypeScript files and passed. + +## Important remaining work + +This patch does not fully complete every item in the hardening plan. The remaining work should be handled before production sign-off: + +- Add/expand DB-backed audit logging for every sensitive mutation. +- Add database-backed idempotency for public booking instead of the current process-local cache. +- Add full route authorization matrix tests across unauthenticated, AGENT, MANAGER, OWNER, wrong tenant, suspended, paused, and past-due states. +- Add storage migration scripts to move existing public reservation photos into private storage. +- Finish token migration for review tokens and any remaining invitation/public access tokens if schema support exists. +- Add full payment provider merchant/receiver-account validation if provider config is stored per company. +- Add dependency/lockfile CI gates from the plan. + diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 289c7b9..7247ff8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,11 +1,13 @@ -import express from 'express' +import express, { type Request, type Response } from 'express' import cors, { type CorsOptions } from 'cors' import helmet from 'helmet' import morgan from 'morgan' import swaggerUi from 'swagger-ui-express' import { openApiDocument } from './swagger/openapi' import { getPublicStorageRoot } from './lib/storage' -import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter' +import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter } from './middleware/rateLimiter' +import { requireTrustedOriginForCookieMutations } from './middleware/csrf' +import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders' import { requestIdMiddleware } from './middleware/requestId' // ─── Module routes ──────────────────────────────────────────── @@ -115,6 +117,7 @@ export function createApp() { app.set('trust proxy', 1) } + app.use(sanitizeForwardedHeaders) app.use(requestIdMiddleware) app.use((req, res, next) => { @@ -125,12 +128,16 @@ export function createApp() { }) app.use(corsMiddleware) + app.use(requireTrustedOriginForCookieMutations) // Customer identity documents must never be anonymously retrievable from the // public storage mount, even if an older raw storage URL leaks. app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => { res.status(404).end() }) + app.use('/storage/companies/:companyId/reservations/:reservationId', (_req, res) => { + res.status(404).end() + }) // Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin // by the browser. Without this header, helmet's default CORP: same-origin reaches @@ -145,15 +152,24 @@ export function createApp() { // through authenticated API routes such as /customers/:id/license-image. app.use('/storage', express.static(getPublicStorageRoot())) - // Swagger UI — mounted before helmet so its assets are not blocked by CSP - app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' })) - app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument)) + const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true' + const docsDisabled = (_req: Request, res: Response) => res.status(404).json({ error: 'not_found', message: 'API docs are not available in this environment', statusCode: 404 }) + + // Swagger UI — never expose API reconnaissance material publicly in production + // unless explicitly enabled for a protected/internal deployment. + if (publicDocsEnabled) { + app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' })) + app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument)) + } else { + app.use('/docs', docsDisabled) + app.get('/api/v1/openapi.json', docsDisabled) + } // Webhooks must use raw body BEFORE express.json(); signature verification // must never reconstruct the payload with JSON.stringify(req.body). - app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter) - app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' })) - app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' })) + app.use(`${v1}/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter) + app.use(`${v1}/payments/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' })) + app.use(`${v1}/subscriptions/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' })) // Let /storage responses manage CORP explicitly so missing files still return // a normal cross-origin 404 instead of being blocked by Helmet's default @@ -227,6 +243,7 @@ export function createApp() { }) app.get(`${v1}/docs`, (_req, res) => { + if (!publicDocsEnabled) return docsDisabled(_req, res) res.json({ name: 'rentaldrivego-api', version: '1.0.0', diff --git a/apps/api/src/http/upload/index.ts b/apps/api/src/http/upload/index.ts index c4df6a9..a66bec1 100644 --- a/apps/api/src/http/upload/index.ts +++ b/apps/api/src/http/upload/index.ts @@ -2,7 +2,7 @@ import path from 'path' import multer from 'multer' import { ValidationError } from '../errors' -const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const ALLOWED_IMAGE_TYPES = new Map([ ['image/jpeg', ['.jpg', '.jpeg']], ['image/png', ['.png']], @@ -16,7 +16,7 @@ const ALLOWED_IMAGE_TYPES = new Map([ */ export const imageUpload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: MAX_FILE_SIZE, files: 20 }, + limits: { fileSize: MAX_FILE_SIZE, files: 5, fields: 20, parts: 30 }, }) type DetectedFile = { mime: string; ext: string } @@ -46,6 +46,50 @@ export function detectImageType(buffer: Buffer): DetectedFile | null { return null } + +function readImageDimensions(file: Express.Multer.File): { width: number; height: number } | null { + const buffer = file.buffer + + if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } + } + + if (buffer.length >= 10 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') { + const chunk = buffer.subarray(12, 16).toString('ascii') + if (chunk === 'VP8X' && buffer.length >= 30) { + const width = 1 + buffer.readUIntLE(24, 3) + const height = 1 + buffer.readUIntLE(27, 3) + return { width, height } + } + } + + if (buffer.length >= 10 && buffer[0] === 0xff && buffer[1] === 0xd8) { + let offset = 2 + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) return null + const marker = buffer[offset + 1] + const length = buffer.readUInt16BE(offset + 2) + if (length < 2) return null + if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { + return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) } + } + offset += 2 + length + } + } + + return null +} + +function assertSafeImageDimensions(file: Express.Multer.File) { + const dimensions = readImageDimensions(file) + if (!dimensions) return + const maxPixels = 24_000_000 + const maxSide = 8_000 + if (dimensions.width <= 0 || dimensions.height <= 0 || dimensions.width > maxSide || dimensions.height > maxSide || dimensions.width * dimensions.height > maxPixels) { + throw new ValidationError(`Image dimensions are too large for "${file.originalname}"`) + } +} + function assertSafeImageContent(file: Express.Multer.File) { const detected = detectImageType(file.buffer) if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) { @@ -61,6 +105,8 @@ function assertSafeImageContent(file: Express.Multer.File) { if (ext && !allowedExtensions.includes(ext)) { throw new ValidationError(`File extension does not match file content for "${file.originalname}"`) } + + assertSafeImageDimensions(file) } /** diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts index af099c3..63c1dd4 100644 --- a/apps/api/src/lib/storage.ts +++ b/apps/api/src/lib/storage.ts @@ -65,6 +65,7 @@ function ensureStorageRoot(visibility: StorageVisibility): string { function inferVisibility(folder: string): StorageVisibility { const normalized = folder.replace(/\\/g, '/') if (/\/customers\//.test(`/${normalized}/`)) return 'private' + if (/\/reservations\//.test(`/${normalized}/`)) return 'private' if (/\/licenses?\//.test(`/${normalized}/`)) return 'private' if (/\/contracts?\//.test(`/${normalized}/`)) return 'private' if (/\/documents?\//.test(`/${normalized}/`)) return 'private' diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..318a50e --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,88 @@ +import type { Request, Response, NextFunction } from 'express' + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) +const SESSION_COOKIE_PATTERN = /(?:^|;\s*)(?:admin_session|employee_session|renter_session)=/ + +function configuredOrigins() { + return [ + process.env.DASHBOARD_URL, + process.env.ADMIN_URL, + process.env.CARPLACE_URL, + process.env.WEBSITE_URL, + process.env.NEXT_PUBLIC_DASHBOARD_URL, + process.env.NEXT_PUBLIC_ADMIN_URL, + process.env.NEXT_PUBLIC_CARPLACE_URL, + process.env.NEXT_PUBLIC_WEBSITE_URL, + process.env.CORS_ORIGINS, + ] + .flatMap((value) => (value ?? '').split(',')) + .map((value) => value.trim()) + .filter(Boolean) +} + +function normalizeOrigin(value: string | undefined): string | null { + if (!value) return null + try { + const url = new URL(value) + return url.origin + } catch { + return null + } +} + +function originFromReferer(value: string | undefined): string | null { + if (!value) return null + try { + return new URL(value).origin + } catch { + return null + } +} + +function isAllowedDevelopmentOrigin(origin: string) { + if (process.env.NODE_ENV === 'production') return false + try { + const url = new URL(origin) + return url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) + } catch { + return false + } +} + +export function isTrustedBrowserOrigin(origin: string | null) { + if (!origin) return false + const allowed = new Set(configuredOrigins().map(normalizeOrigin).filter((value): value is string => Boolean(value))) + return allowed.has(origin) || isAllowedDevelopmentOrigin(origin) +} + +function isCookieAuthenticatedBrowserMutation(req: Request) { + if (!MUTATING_METHODS.has(req.method.toUpperCase())) return false + const cookie = req.headers.cookie ?? '' + if (!SESSION_COOKIE_PATTERN.test(cookie)) return false + + const secFetchSite = Array.isArray(req.headers['sec-fetch-site']) + ? req.headers['sec-fetch-site'][0] + : req.headers['sec-fetch-site'] + if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') return true + + // Browser cookie-authenticated mutations must present Origin. Referer is a + // fallback for older clients only; API clients should use Bearer tokens. + return true +} + +export function requireTrustedOriginForCookieMutations(req: Request, res: Response, next: NextFunction) { + if (!isCookieAuthenticatedBrowserMutation(req)) return next() + + const origin = normalizeOrigin(req.headers.origin as string | undefined) + ?? originFromReferer(req.headers.referer as string | undefined) + + if (!isTrustedBrowserOrigin(origin)) { + return res.status(403).json({ + error: 'csrf_origin_rejected', + message: 'Mutating cookie-authenticated requests must come from a trusted application origin.', + statusCode: 403, + }) + } + + next() +} diff --git a/apps/api/src/middleware/forwardedHeaders.ts b/apps/api/src/middleware/forwardedHeaders.ts new file mode 100644 index 0000000..5939f87 --- /dev/null +++ b/apps/api/src/middleware/forwardedHeaders.ts @@ -0,0 +1,26 @@ +import type { Request, Response, NextFunction } from 'express' + +export const SPOOFABLE_FORWARDING_HEADERS = [ + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', + 'forwarded', + 'cf-connecting-ip', + 'true-client-ip', + 'x-client-ip', +] + +/** + * Drop client-supplied forwarding headers unless the deployment explicitly + * states that the immediate proxy has already scrubbed and reset them. + */ +export function sanitizeForwardedHeaders(req: Request, _res: Response, next: NextFunction) { + if (process.env.TRUSTED_FORWARD_HEADERS === 'true') return next() + + for (const header of SPOOFABLE_FORWARDING_HEADERS) { + delete req.headers[header] + } + + next() +} diff --git a/apps/api/src/middleware/rateLimiter.ts b/apps/api/src/middleware/rateLimiter.ts index 56a228d..97d53ba 100644 --- a/apps/api/src/middleware/rateLimiter.ts +++ b/apps/api/src/middleware/rateLimiter.ts @@ -92,6 +92,17 @@ 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({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: (req) => getClientIpKey(req), + message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 }, +}) + // Tight limiter for admin endpoints export const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, diff --git a/apps/api/src/middleware/requireAdminAuth.test.ts b/apps/api/src/middleware/requireAdminAuth.test.ts index df87cb3..0793895 100644 --- a/apps/api/src/middleware/requireAdminAuth.test.ts +++ b/apps/api/src/middleware/requireAdminAuth.test.ts @@ -123,7 +123,7 @@ describe('requireAdminRole middleware', () => { expect(next).not.toHaveBeenCalled() }) - it('blocks admins below the required rank', () => { + it('blocks admins without the explicit required admin role', () => { const req = { admin: { role: 'VIEWER' } } as Request const res = responseStub() const next = vi.fn() as NextFunction @@ -133,13 +133,26 @@ describe('requireAdminRole middleware', () => { expect(res.status).toHaveBeenCalledWith(403) expect(res.json).toHaveBeenCalledWith({ error: 'forbidden', - message: 'This action requires the FINANCE role or higher', + message: 'This action requires explicit FINANCE permission', statusCode: 403, }) expect(next).not.toHaveBeenCalled() }) - it('allows admins at or above the required rank', () => { + + + it('does not allow SUPPORT to access FINANCE-only routes', () => { + const req = { admin: { role: 'SUPPORT' } } as Request + const res = responseStub() + const next = vi.fn() as NextFunction + + requireAdminRole('FINANCE' as any)(req, res, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + + it('allows admins explicitly permitted for the required admin role', () => { const req = { admin: { role: 'ADMIN' } } as Request const res = responseStub() const next = vi.fn() as NextFunction diff --git a/apps/api/src/middleware/requireAdminAuth.ts b/apps/api/src/middleware/requireAdminAuth.ts index 0972321..0ce754d 100644 --- a/apps/api/src/middleware/requireAdminAuth.ts +++ b/apps/api/src/middleware/requireAdminAuth.ts @@ -5,12 +5,12 @@ import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers' import { verifyActorToken } from '../security/tokens' import { getSessionCookieName } from '../security/sessionCookies' -const ROLE_RANK: Record = { - SUPER_ADMIN: 5, - ADMIN: 4, - SUPPORT: 3, - FINANCE: 2, - VIEWER: 1, +const ADMIN_ROLE_ALLOWLIST: Record = { + SUPER_ADMIN: ['SUPER_ADMIN'], + ADMIN: ['SUPER_ADMIN', 'ADMIN'], + SUPPORT: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT'], + FINANCE: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'], + VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'], } const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([ @@ -67,11 +67,10 @@ export function requireAdminRole(minimumRole: AdminRole) { const admin = req.admin if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required') - const rank = ROLE_RANK[admin.role] ?? 0 - const required = ROLE_RANK[minimumRole] ?? 99 + const allowedRoles = ADMIN_ROLE_ALLOWLIST[minimumRole] ?? [] - if (rank < required) { - return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`) + if (!allowedRoles.includes(admin.role)) { + return sendForbidden(res, 'forbidden', `This action requires explicit ${minimumRole} permission`) } next() @@ -92,3 +91,11 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc next() } + + +export function requireFreshAdmin2FAWhenEnabled(req: Request, res: Response, next: NextFunction) { + const admin = req.admin + if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required') + if (!admin.totpEnabled) return next() + return requireFreshAdmin2FA(req, res, next) +} diff --git a/apps/api/src/middleware/requireSubscription.ts b/apps/api/src/middleware/requireSubscription.ts index b5efbd8..eee3b9e 100644 --- a/apps/api/src/middleware/requireSubscription.ts +++ b/apps/api/src/middleware/requireSubscription.ts @@ -1,50 +1,52 @@ import { Request, Response, NextFunction } from 'express' import { prisma } from '../lib/prisma' -import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy' +import { getAccessLevel, hasAnyAccess, hasFullAccess, hasWriteAccess } from '../modules/subscriptions/subscription.policy' import { sendUnauthorized, sendPaymentRequired } from './authHelpers' -const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] +const COMPANY_READ_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING'] +const COMPANY_WRITE_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING', 'PAUSED'] + +function billingUrl() { + return `${process.env.NEXT_PUBLIC_DASHBOARD_URL ?? process.env.DASHBOARD_URL ?? '/dashboard'}/subscription` +} + +async function getSubscriptionStatus(companyId: string) { + const subscription = await prisma.subscription.findUnique({ + where: { companyId }, + select: { status: true }, + }) + return subscription?.status ?? 'EXPIRED' +} + +function blockSubscription(res: Response, error: string, message: string, extra?: Record) { + return sendPaymentRequired(res, error, message, { billingUrl: billingUrl(), ...extra }) +} /** - * Blocks requests for companies with lapsed or unactivated subscriptions. - * Must be applied after `requireTenant`. - * - * Guarantees on success: - * req.company.status is not SUSPENDED or PENDING, and subscription access is not none + * Read access: allows healthy read-only states, but blocks companies that should + * not have normal application visibility at all. */ -export async function requireSubscription(req: Request, res: Response, next: NextFunction) { +export async function requireSubscriptionRead(req: Request, res: Response, next: NextFunction) { try { const company = req.company if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') - if (BLOCKED_STATUSES.includes(company.status)) { - return sendPaymentRequired( + if (COMPANY_READ_BLOCKED_STATUSES.includes(company.status)) { + return blockSubscription( res, `subscription_${company.status.toLowerCase()}`, company.status === 'SUSPENDED' ? 'Your account has been suspended. Please contact support or renew your subscription.' : 'Your account is pending activation. Please complete your subscription setup.', - { billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` }, ) } - const subscription = await prisma.subscription.findUnique({ - where: { companyId: company.id }, - select: { status: true }, - }) - const subscriptionStatus = subscription?.status ?? 'EXPIRED' - + const subscriptionStatus = await getSubscriptionStatus(company.id) if (!hasAnyAccess(subscriptionStatus)) { - return sendPaymentRequired( - res, - 'subscription_required', - 'Your subscription has ended. Please reactivate to continue.', - { - billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`, - subscriptionStatus, - accessLevel: getAccessLevel(subscriptionStatus), - }, - ) + return blockSubscription(res, 'subscription_required', 'Your subscription has ended. Please reactivate to continue.', { + subscriptionStatus, + accessLevel: getAccessLevel(subscriptionStatus), + }) } next() @@ -52,3 +54,58 @@ export async function requireSubscription(req: Request, res: Response, next: Nex next(error) } } + +/** + * Write access: blocks read-only/limited subscription states from mutating data. + */ +export async function requireSubscriptionWrite(req: Request, res: Response, next: NextFunction) { + try { + const company = req.company + if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') + + if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) { + return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow changes.') + } + + const subscriptionStatus = await getSubscriptionStatus(company.id) + if (!hasWriteAccess(subscriptionStatus)) { + return blockSubscription(res, 'subscription_write_required', 'Your subscription is read-only. Reactivate or update billing to make changes.', { + subscriptionStatus, + accessLevel: getAccessLevel(subscriptionStatus), + }) + } + + next() + } catch (error) { + next(error) + } +} + +/** + * Full access: required for booking/payment/billing-sensitive actions. + */ +export async function requireSubscriptionFull(req: Request, res: Response, next: NextFunction) { + try { + const company = req.company + if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context') + + if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) { + return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow this action.') + } + + const subscriptionStatus = await getSubscriptionStatus(company.id) + if (!hasFullAccess(subscriptionStatus)) { + return blockSubscription(res, 'subscription_full_access_required', 'This action requires an active subscription in good standing.', { + subscriptionStatus, + accessLevel: getAccessLevel(subscriptionStatus), + }) + } + + next() + } catch (error) { + next(error) + } +} + +// Backward-compatible alias. New routes should choose read/write/full explicitly. +export const requireSubscription = requireSubscriptionRead diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index a815a7c..5808ea8 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -1,4 +1,5 @@ import { prisma } from '../../lib/prisma' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' const companyListInclude = { brand: { select: { displayName: true, logoUrl: true, subdomain: true } }, @@ -80,17 +81,18 @@ export function markAdminRecoveryCodeUsed(id: string) { return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } }) } -export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) { +export function setAdminPasswordReset(id: string, tokenHash: string, expiresAt: Date) { return prisma.adminUser.update({ where: { id }, - data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt }, + data: { passwordResetToken: tokenHash, passwordResetExpiresAt: expiresAt }, }) } export function findAdminByResetToken(token: string) { + const tokenHash = hashPublicAccessToken(token) return prisma.adminUser.findFirst({ where: { - passwordResetToken: token, + OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }], passwordResetExpiresAt: { gt: new Date() }, }, }) diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index a1509a7..2024314 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express' -import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth' +import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA, requireFreshAdmin2FAWhenEnabled } from '../../middleware/requireAdminAuth' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies' @@ -80,7 +80,7 @@ router.get('/auth/me', requireAdminAuth, (req, res) => { ok(res, presentAdminUser(req.admin as any)) }) -router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => { +router.post('/auth/2fa/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => { try { ok(res, await service.setupTotp(req.admin.id, req.admin.email)) } catch (err) { next(err) } diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index d8486c9..9b21d42 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcryptjs' import crypto from 'crypto' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { authenticator } from 'otplib' import { signActorToken } from '../../security/tokens' import qrcode from 'qrcode' @@ -144,7 +145,7 @@ export async function forgotPassword(email: string) { const rawToken = crypto.randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000) - await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt) + await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt) const adminUrl = ensureAdminBasePath( process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin', diff --git a/apps/api/src/modules/analytics/analytics.routes.ts b/apps/api/src/modules/analytics/analytics.routes.ts index 5872054..c7efbd5 100644 --- a/apps/api/src/modules/analytics/analytics.routes.ts +++ b/apps/api/src/modules/analytics/analytics.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseQuery } from '../../http/validate' import { ok } from '../../http/respond' @@ -10,22 +10,22 @@ import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas' const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) -router.get('/summary', async (req, res, next) => { +router.get('/summary', requireRole('MANAGER'), async (req, res, next) => { try { const { period } = parseQuery(summaryQuerySchema, req) ok(res, await service.getSummary(req.companyId, period)) } catch (err) { next(err) } }) -router.get('/dashboard', async (req, res, next) => { +router.get('/dashboard', requireRole('MANAGER'), async (req, res, next) => { try { ok(res, await service.getDashboard(req.companyId)) } catch (err) { next(err) } }) -router.get('/sources', async (req, res, next) => { +router.get('/sources', requireRole('MANAGER'), async (req, res, next) => { try { ok(res, await service.getSources(req.companyId)) } catch (err) { next(err) } diff --git a/apps/api/src/modules/auth/auth.account.service.ts b/apps/api/src/modules/auth/auth.account.service.ts index ed4babc..0f0ee93 100644 --- a/apps/api/src/modules/auth/auth.account.service.ts +++ b/apps/api/src/modules/auth/auth.account.service.ts @@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs' import crypto from 'crypto' import { AppError } from '../../http/errors' import { prisma } from '../../lib/prisma' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { sendTransactionalEmail } from '../../services/notificationService' import * as repo from './auth.company.repo' import type { output } from 'zod' @@ -81,7 +82,7 @@ export async function startAccount(body: AccountStartInput) { lastName: '', email: body.email, passwordHash, - emailVerificationToken: verificationToken, + emailVerificationToken: hashPublicAccessToken(verificationToken), role: 'OWNER', preferredLanguage: body.preferredLanguage, isActive: true, diff --git a/apps/api/src/modules/auth/auth.employee.repo.ts b/apps/api/src/modules/auth/auth.employee.repo.ts index 25549c5..44db023 100644 --- a/apps/api/src/modules/auth/auth.employee.repo.ts +++ b/apps/api/src/modules/auth/auth.employee.repo.ts @@ -1,4 +1,5 @@ import { prisma } from '../../lib/prisma' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' export function findEmployeeWithCompanyById(id: string) { return prisma.employee.findUnique({ @@ -52,9 +53,10 @@ export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'f } export function findEmployeeByResetToken(token: string) { + const tokenHash = hashPublicAccessToken(token) return prisma.employee.findFirst({ where: { - passwordResetToken: token, + OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }], passwordResetExpiresAt: { gt: new Date() }, }, }) @@ -71,16 +73,17 @@ export function resetPassword(id: string, passwordHash: string) { }) } -export function setEmailVerificationToken(id: string, token: string) { +export function setEmailVerificationToken(id: string, tokenHash: string) { return prisma.employee.update({ where: { id }, - data: { emailVerificationToken: token }, + data: { emailVerificationToken: tokenHash }, }) } export function findEmployeeByVerificationToken(token: string) { + const tokenHash = hashPublicAccessToken(token) return prisma.employee.findFirst({ - where: { emailVerificationToken: token }, + where: { OR: [{ emailVerificationToken: tokenHash }, { emailVerificationToken: token }] }, include: { company: true }, }) } diff --git a/apps/api/src/modules/auth/auth.employee.service.ts b/apps/api/src/modules/auth/auth.employee.service.ts index 7c8b5bb..50fe26c 100644 --- a/apps/api/src/modules/auth/auth.employee.service.ts +++ b/apps/api/src/modules/auth/auth.employee.service.ts @@ -3,6 +3,7 @@ import crypto from 'crypto' import jwt from 'jsonwebtoken' import { signActorToken } from '../../security/tokens' import { AppError } from '../../http/errors' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { sendTransactionalEmail } from '../../services/notificationService' import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations' import { presentEmployeeSession } from './auth.presenter' @@ -204,7 +205,7 @@ export async function resendVerification(email: string) { } const verificationToken = crypto.randomBytes(32).toString('hex') - await repo.setEmailVerificationToken(employee.id, verificationToken) + await repo.setEmailVerificationToken(employee.id, hashPublicAccessToken(verificationToken)) const dashboardUrl = ensureDashboardBasePath( process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard', diff --git a/apps/api/src/modules/billing/billing.routes.ts b/apps/api/src/modules/billing/billing.routes.ts index 26089c4..80ba191 100644 --- a/apps/api/src/modules/billing/billing.routes.ts +++ b/apps/api/src/modules/billing/billing.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseParams, parseQuery } from '../../http/validate' import { ok } from '../../http/respond' @@ -15,7 +15,7 @@ import { const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('OWNER')) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead, requireRole('OWNER')) router.get('/summary', async (req, res, next) => { try { @@ -38,7 +38,7 @@ router.get('/invoices/:invoiceId', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/invoices/:invoiceId/payments/manual', async (req, res, next) => { +router.post('/invoices/:invoiceId/payments/manual', requireSubscriptionFull, async (req, res, next) => { try { const { invoiceId } = parseParams(invoiceParamSchema, req) const body = parseBody(manualBillingPaymentSchema, req) diff --git a/apps/api/src/modules/carplace/carplace.repo.ts b/apps/api/src/modules/carplace/carplace.repo.ts index 39c72b5..b4b3186 100644 --- a/apps/api/src/modules/carplace/carplace.repo.ts +++ b/apps/api/src/modules/carplace/carplace.repo.ts @@ -43,7 +43,7 @@ export async function findPublishedVehicles(where: any) { export async function findVehicleForCarplace(vehicleId: string, companySlug: string) { return prisma.vehicle.findFirst({ - where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } }, + where: { id: vehicleId, isPublished: true, status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } }, include: { company: { include: { brand: true } } }, }) } @@ -53,6 +53,7 @@ export async function findVehicleForCarplaceById(vehicleId: string) { where: { id: vehicleId, isPublished: true, + status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } }, }, include: { company: { include: { brand: true } } }, @@ -139,6 +140,7 @@ export async function createCarplaceFunnelEvent(data: { id: data.vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] }, + status: 'AVAILABLE', }, select: { companyId: true }, }) diff --git a/apps/api/src/modules/carplace/carplace.schemas.ts b/apps/api/src/modules/carplace/carplace.schemas.ts index ed803e1..759d58d 100644 --- a/apps/api/src/modules/carplace/carplace.schemas.ts +++ b/apps/api/src/modules/carplace/carplace.schemas.ts @@ -1,8 +1,8 @@ import { z } from 'zod' export const paginationSchema = z.object({ - page: z.coerce.number().int().min(1).max(10000).default(1), - pageSize: z.coerce.number().int().min(1).max(100).default(20), + page: z.coerce.number().int().min(1).max(100).default(1), + pageSize: z.coerce.number().int().min(1).max(50).default(20), }) export const searchSchema = z.object({ @@ -83,7 +83,7 @@ export const carplaceFunnelEventSchema = z.object({ vehicleId: z.string().cuid(), sessionId: z.string().trim().max(100).optional(), path: z.string().trim().max(500).optional(), - metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), + metadata: z.record(z.string().max(50), z.union([z.string().max(500), z.number(), z.boolean(), z.null()])).refine((value) => Object.keys(value).length <= 20, 'metadata may contain at most 20 keys').optional(), }) export const reviewBodySchema = z.object({ diff --git a/apps/api/src/modules/companies/company.routes.ts b/apps/api/src/modules/companies/company.routes.ts index adebcf8..91f5e61 100644 --- a/apps/api/src/modules/companies/company.routes.ts +++ b/apps/api/src/modules/companies/company.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy' import { parseBody, parseParams } from '../../http/validate' @@ -22,7 +22,7 @@ import { const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/me', async (req, res, next) => { try { @@ -31,7 +31,7 @@ router.get('/me', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/me', requireRole('OWNER'), async (req, res, next) => { +router.patch('/me', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(companySchema, req) const company = await service.updateCompany(req.companyId, body) @@ -46,7 +46,7 @@ router.get('/me/brand', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => { +router.patch('/me/brand', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => { try { const body = parseBody(brandSchema, req) const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug) @@ -54,7 +54,7 @@ router.patch('/me/brand', requireRole('OWNER'), requireSettingsFeature('settings } catch (err) { next(err) } }) -router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => { +router.post('/me/brand/logo', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), imageUpload.single('file'), async (req, res, next) => { try { assertImageFile(req.file, 'logo') const brand = await service.uploadLogo(req.companyId, req.company.name, req.company.slug, req.file.buffer) @@ -62,7 +62,7 @@ router.post('/me/brand/logo', requireRole('OWNER'), requireSettingsFeature('sett } catch (err) { next(err) } }) -router.post('/me/brand/hero', requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => { +router.post('/me/brand/hero', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_hero'), imageUpload.single('file'), async (req, res, next) => { try { assertImageFile(req.file, 'hero image') const brand = await service.uploadHeroImage(req.companyId, req.company.name, req.company.slug, req.file.buffer) @@ -82,7 +82,7 @@ router.get('/me/settings-entitlements', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => { +router.post('/me/brand/subdomain/check', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { subdomain } = parseBody(subdomainSchema, req) const result = await service.checkSubdomainAvailability(subdomain, req.companyId) @@ -90,7 +90,7 @@ router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, } catch (err) { next(err) } }) -router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => { +router.post('/me/brand/custom-domain', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { customDomain } = parseBody(customDomainSchema, req) const brand = await service.setCustomDomain(req.companyId, req.company.name, req.company.slug, customDomain) @@ -105,21 +105,21 @@ router.get('/me/brand/custom-domain/status', async (req, res, next) => { } catch (err) { next(err) } }) -router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => { +router.delete('/me/brand/custom-domain', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const result = await service.removeCustomDomain(req.companyId) ok(res, result) } catch (err) { next(err) } }) -router.get('/me/contract-settings', async (req, res, next) => { +router.get('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => { try { const settings = await service.getContractSettings(req.companyId) ok(res, settings) } catch (err) { next(err) } }) -router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => { +router.patch('/me/contract-settings', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.rental_policies_basic'), async (req, res, next) => { try { const body = parseBody(contractSettingsSchema, req) const settings = await service.updateContractSettings(req.companyId, body) @@ -127,14 +127,14 @@ router.patch('/me/contract-settings', requireRole('MANAGER'), requireSettingsFea } catch (err) { next(err) } }) -router.get('/me/insurance-policies', async (req, res, next) => { +router.get('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => { try { const policies = await service.getInsurancePolicies(req.companyId) ok(res, policies) } catch (err) { next(err) } }) -router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { +router.post('/me/insurance-policies', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { try { const body = parseBody(insurancePolicySchema, req) const policy = await service.createInsurancePolicy(req.companyId, body) @@ -142,7 +142,7 @@ router.post('/me/insurance-policies', requireRole('MANAGER'), requireSettingsFea } catch (err) { next(err) } }) -router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { +router.patch('/me/insurance-policies/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(insurancePolicySchema.partial(), req) @@ -151,7 +151,7 @@ router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettin } catch (err) { next(err) } }) -router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { +router.delete('/me/insurance-policies/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.insurance_policies'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.deleteInsurancePolicy(id, req.companyId) @@ -159,14 +159,14 @@ router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), requireSetti } catch (err) { next(err) } }) -router.get('/me/pricing-rules', async (req, res, next) => { +router.get('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => { try { const rules = await service.getPricingRules(req.companyId) ok(res, rules) } catch (err) { next(err) } }) -router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { +router.post('/me/pricing-rules', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { try { const body = parseBody(pricingRuleSchema, req) const rule = await service.createPricingRule(req.companyId, body) @@ -174,7 +174,7 @@ router.post('/me/pricing-rules', requireRole('MANAGER'), requireSettingsFeature( } catch (err) { next(err) } }) -router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { +router.patch('/me/pricing-rules/:id', requireSubscriptionWrite, requireRole('MANAGER'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(pricingRuleSchema.partial(), req) @@ -183,7 +183,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), requireSettingsFea } catch (err) { next(err) } }) -router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { +router.delete('/me/pricing-rules/:id', requireSubscriptionWrite, requireCompanyPolicy('deletePricingRule'), requireSettingsFeature('settings.pricing_rules'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.deletePricingRule(id, req.companyId) @@ -191,14 +191,14 @@ router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule') } catch (err) { next(err) } }) -router.get('/me/accounting-settings', async (req, res, next) => { +router.get('/me/accounting-settings', requireRole('MANAGER'), async (req, res, next) => { try { const settings = await service.getAccountingSettings(req.companyId) ok(res, settings) } catch (err) { next(err) } }) -router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => { +router.patch('/me/accounting-settings', requireSubscriptionWrite, requireCompanyPolicy('updateAccountingSettings'), requireSettingsFeature('settings.accounting_defaults'), async (req, res, next) => { try { const body = parseBody(accountingSettingsSchema, req) const settings = await service.updateAccountingSettings(req.companyId, body) @@ -213,7 +213,7 @@ router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, n } catch (err) { next(err) } }) -router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => { +router.post('/me/api-key/regenerate', requireSubscriptionWrite, requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => { try { const company = await service.regenerateApiKey(req.companyId) ok(res, company) diff --git a/apps/api/src/modules/complaints/complaint.routes.ts b/apps/api/src/modules/complaints/complaint.routes.ts index 0985a0f..34af36b 100644 --- a/apps/api/src/modules/complaints/complaint.routes.ts +++ b/apps/api/src/modules/complaints/complaint.routes.ts @@ -1,7 +1,8 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' +import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' import * as service from './complaint.service' @@ -9,7 +10,7 @@ import { createSchema, updateSchema, listQuerySchema, idParamSchema } from './co const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { @@ -19,7 +20,7 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/', async (req, res, next) => { +router.post('/', requireSubscriptionWrite, async (req, res, next) => { try { const body = parseBody(createSchema, req) const complaint = await service.createComplaint(req.companyId, body) @@ -35,7 +36,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id', async (req, res, next) => { +router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(updateSchema, req) @@ -44,7 +45,7 @@ router.patch('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const result = await service.deleteComplaint(id, req.companyId) diff --git a/apps/api/src/modules/customers/customer.routes.ts b/apps/api/src/modules/customers/customer.routes.ts index 4a32752..e03e0bf 100644 --- a/apps/api/src/modules/customers/customer.routes.ts +++ b/apps/api/src/modules/customers/customer.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth, requireCompanyDocumentAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' @@ -11,7 +11,7 @@ import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idPa const router = Router() -router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscription, async (req, res, next) => { +router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requireSubscriptionRead, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const filePath = await service.getLicenseImageFile(id, req.companyId) @@ -19,7 +19,7 @@ router.get('/:id/license-image', requireCompanyDocumentAuth, requireTenant, requ } catch (err) { next(err) } }) -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { const query = parseQuery(listQuerySchema, req) @@ -28,7 +28,7 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/', async (req, res, next) => { +router.post('/', requireSubscriptionWrite, async (req, res, next) => { try { const body = parseBody(customerSchema, req) const customer = await service.createCustomer(body, req.companyId) @@ -44,7 +44,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(customerSchema.partial(), req) @@ -53,7 +53,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/flag', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { reason } = parseBody(flagSchema, req) @@ -62,7 +62,7 @@ router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id/flag', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.unflagCustomer(id, req.companyId) @@ -70,7 +70,7 @@ router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/validate-license', async (req, res, next) => { +router.post('/:id/validate-license', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const result = await service.validateCustomerLicense(id, req.companyId) @@ -78,7 +78,7 @@ router.post('/:id/validate-license', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/license-image', imageUpload.single('file'), async (req, res, next) => { +router.post('/:id/license-image', requireSubscriptionWrite, imageUpload.single('file'), async (req, res, next) => { try { assertImageFile(req.file, 'license image') const { id } = parseParams(idParamSchema, req) @@ -87,7 +87,7 @@ router.post('/:id/license-image', imageUpload.single('file'), async (req, res, n } catch (err) { next(err) } }) -router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/approve-license', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { decision, note } = parseBody(approveLicenseSchema, req) diff --git a/apps/api/src/modules/notifications/notification.routes.ts b/apps/api/src/modules/notifications/notification.routes.ts index 29300d1..5b115ad 100644 --- a/apps/api/src/modules/notifications/notification.routes.ts +++ b/apps/api/src/modules/notifications/notification.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRenterAuth } from '../../middleware/requireRenterAuth' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok } from '../../http/respond' @@ -10,24 +10,25 @@ import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema const router = Router() -const companyAuth = [requireCompanyAuth, requireTenant, requireSubscription] as const +const companyReadAuth = [requireCompanyAuth, requireTenant, requireSubscriptionRead] as const +const companyWriteAuth = [requireCompanyAuth, requireTenant, requireSubscriptionRead, requireSubscriptionWrite] as const // ─── Company notifications ──────────────────────────────────── -router.get('/company', ...companyAuth, async (req, res, next) => { +router.get('/company', ...companyReadAuth, async (req, res, next) => { try { const { unread } = parseQuery(unreadQuerySchema, req) ok(res, await service.listCompany(req.companyId, unread)) } catch (err) { next(err) } }) -router.get('/unread-count', ...companyAuth, async (req, res, next) => { +router.get('/unread-count', ...companyReadAuth, async (req, res, next) => { try { ok(res, { unread: await service.countUnread(req.companyId) }) } catch (err) { next(err) } }) -router.post('/company/:id/read', ...companyAuth, async (req, res, next) => { +router.post('/company/:id/read', ...companyWriteAuth, async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.markRead(id, req.companyId) @@ -35,20 +36,20 @@ router.post('/company/:id/read', ...companyAuth, async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/company/read-all', ...companyAuth, async (req, res, next) => { +router.post('/company/read-all', ...companyWriteAuth, async (req, res, next) => { try { await service.markAllRead(req.companyId) ok(res, { success: true }) } catch (err) { next(err) } }) -router.get('/company/preferences', ...companyAuth, async (req, res, next) => { +router.get('/company/preferences', ...companyReadAuth, async (req, res, next) => { try { ok(res, await service.getPreferences(req.employee.id)) } catch (err) { next(err) } }) -router.patch('/company/preferences', ...companyAuth, async (req, res, next) => { +router.patch('/company/preferences', ...companyWriteAuth, async (req, res, next) => { try { const prefs = parseBody(preferencesSchema, req) await service.setPreferences(req.employee.id, prefs) @@ -56,7 +57,7 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => { } catch (err) { next(err) } }) -router.get('/history', ...companyAuth, async (req, res, next) => { +router.get('/history', ...companyReadAuth, async (req, res, next) => { try { const { channel, status, limit } = parseQuery(historyQuerySchema, req) ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit })) diff --git a/apps/api/src/modules/offers/offer.routes.ts b/apps/api/src/modules/offers/offer.routes.ts index abd6bf9..373e885 100644 --- a/apps/api/src/modules/offers/offer.routes.ts +++ b/apps/api/src/modules/offers/offer.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' @@ -10,7 +10,7 @@ import { offerSchema, listQuerySchema, idParamSchema } from './offer.schemas' const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { @@ -19,7 +19,7 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/', requireRole('MANAGER'), async (req, res, next) => { +router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { vehicleIds, ...body } = parseBody(offerSchema, req) created(res, await service.createOffer(req.companyId, body, vehicleIds)) @@ -33,7 +33,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { vehicleIds, ...body } = parseBody(offerSchema.partial(), req) @@ -41,7 +41,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.deleteOffer(id, req.companyId) @@ -49,7 +49,7 @@ router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/activate', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.setOfferActive(id, req.companyId, true) @@ -57,7 +57,7 @@ router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/deactivate', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.setOfferActive(id, req.companyId, false) diff --git a/apps/api/src/modules/payments/payment.repo.ts b/apps/api/src/modules/payments/payment.repo.ts index 7c1ee5f..b8eb819 100644 --- a/apps/api/src/modules/payments/payment.repo.ts +++ b/apps/api/src/modules/payments/payment.repo.ts @@ -85,6 +85,17 @@ export function updatePaypalCapture(id: string, captureId: string) { return prisma.rentalPayment.update({ where: { id }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } }) } +export async function updatePendingPaypalCapture(id: string, captureId: string) { + return prisma.$transaction(async (tx) => { + const result = await tx.rentalPayment.updateMany({ + where: { id, status: 'PENDING' }, + data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId }, + }) + if (result.count !== 1) throw new Error('Payment is no longer pending') + return tx.rentalPayment.findUniqueOrThrow({ where: { id } }) + }) +} + export function setReservationPaidAmount(reservationId: string, paidAmount: number, paymentStatus: string) { return prisma.reservation.update({ where: { id: reservationId }, data: { paidAmount, paymentStatus: paymentStatus as any } }) } diff --git a/apps/api/src/modules/payments/payment.routes.ts b/apps/api/src/modules/payments/payment.routes.ts index fa708e1..9fb2d2b 100644 --- a/apps/api/src/modules/payments/payment.routes.ts +++ b/apps/api/src/modules/payments/payment.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseParams } from '../../http/validate' import { ok } from '../../http/respond' @@ -41,22 +41,22 @@ router.post('/webhooks/paypal', async (req, res, next) => { // ─── Authenticated ──────────────────────────────────────────── -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) -router.get('/company', async (req, res, next) => { +router.get('/company', requireRole('MANAGER'), async (req, res, next) => { try { ok(res, await service.listByCompany(req.companyId)) } catch (err) { next(err) } }) -router.get('/reservations/:id', async (req, res, next) => { +router.get('/reservations/:id', requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) ok(res, await service.listByReservation(id, req.companyId)) } catch (err) { next(err) } }) -router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => { +router.post('/reservations/:id/charge', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) const body = parseBody(chargeSchema, req) @@ -64,7 +64,7 @@ router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, } catch (err) { next(err) } }) -router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => { +router.post('/reservations/:id/capture-paypal', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) const { paypalOrderId } = parseBody(capturePaypalSchema, req) @@ -72,7 +72,7 @@ router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (r } catch (err) { next(err) } }) -router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, next) => { +router.post('/reservations/:id/manual', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(reservationParamSchema, req) const body = parseBody(manualPaymentSchema, req) @@ -80,7 +80,7 @@ router.post('/reservations/:id/manual', requireRole('OWNER'), async (req, res, n } catch (err) { next(err) } }) -router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => { +router.post('/reservations/:reservationId/payments/:paymentId/refund', requireSubscriptionFull, requireRole('MANAGER'), async (req, res, next) => { try { const { reservationId, paymentId } = parseParams(paymentParamSchema, req) const { amount, reason } = parseBody(refundSchema, req) diff --git a/apps/api/src/modules/payments/payment.service.ts b/apps/api/src/modules/payments/payment.service.ts index b11b947..c3817b4 100644 --- a/apps/api/src/modules/payments/payment.service.ts +++ b/apps/api/src/modules/payments/payment.service.ts @@ -122,11 +122,45 @@ export async function initCharge(reservationId: string, companyId: string, body: return { payment, checkoutUrl } } +function amountToMinorUnits(value: unknown) { + const numeric = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(numeric)) return null + return Math.round(numeric * 100) +} + export async function capturePaypal(reservationId: string, companyId: string, paypalOrderId: string) { - const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId) - const capture = await paypal.captureOrder(paypalOrderId) as Record - const captureId = capture.purchase_units?.[0]?.payments?.captures?.[0]?.id ?? paypalOrderId - const updated = await repo.updatePaypalCapture(payment.id, captureId) + const payment = await repo.findByPaypalForCompany(paypalOrderId, companyId) + + if (payment.reservationId !== reservationId) { + throw new ValidationError('PayPal order does not belong to this reservation') + } + if (payment.status !== 'PENDING') { + throw new ConflictError('This payment has already been processed') + } + + const capture = await paypal.captureOrder(paypalOrderId) as Record + if (capture.status !== 'COMPLETED') { + throw new ValidationError('PayPal capture did not complete') + } + + const unit = capture.purchase_units?.[0] + const providerCapture = unit?.payments?.captures?.[0] + const captureId = providerCapture?.id + const providerAmount = amountToMinorUnits(providerCapture?.amount?.value) + const providerCurrency = providerCapture?.amount?.currency_code + + if (!captureId) throw new ValidationError('PayPal capture response did not include a capture id') + if (unit?.reference_id && !String(unit.reference_id).startsWith(reservationId)) { + throw new ValidationError('PayPal order reference does not match reservation') + } + if (providerAmount !== payment.amount) { + throw new ValidationError('PayPal capture amount does not match expected amount') + } + if (providerCurrency !== payment.currency) { + throw new ValidationError('PayPal capture currency does not match expected currency') + } + + const updated = await repo.updatePendingPaypalCapture(payment.id, captureId) if (payment.type === 'CHARGE') { await repo.incrementReservationPaid(payment.reservationId, payment.amount) } diff --git a/apps/api/src/modules/reservations/reservation.routes.ts b/apps/api/src/modules/reservations/reservation.routes.ts index e6ab0ee..5948165 100644 --- a/apps/api/src/modules/reservations/reservation.routes.ts +++ b/apps/api/src/modules/reservations/reservation.routes.ts @@ -2,7 +2,8 @@ import { Router } from 'express' import { z } from 'zod' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' +import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' import { imageUpload, assertImageFile } from '../../http/upload' @@ -23,7 +24,7 @@ const photoTypeSchema = z.enum(['PICKUP', 'DROPOFF']) const photoParamSchema = z.object({ id: z.string(), photoId: z.string() }) const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { @@ -33,7 +34,7 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/', async (req, res, next) => { +router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const body = parseBody(createSchema, req) const reservation = await service.createReservation(req.companyId, body) @@ -49,7 +50,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id', async (req, res, next) => { +router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(updateSchema, req) @@ -74,7 +75,7 @@ router.get('/:id/billing', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/confirm', async (req, res, next) => { +router.post('/:id/confirm', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const updated = await lifecycle.confirmReservation(id, req.companyId) @@ -82,7 +83,7 @@ router.post('/:id/confirm', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/checkin', async (req, res, next) => { +router.post('/:id/checkin', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { mileage } = parseBody(checkinSchema, req) @@ -91,7 +92,7 @@ router.post('/:id/checkin', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/checkout', async (req, res, next) => { +router.post('/:id/checkout', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { mileage } = parseBody(checkoutSchema, req) @@ -100,7 +101,7 @@ router.post('/:id/checkout', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/close', async (req, res, next) => { +router.post('/:id/close', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const closedBy = `${req.employee.firstName} ${req.employee.lastName}` @@ -109,7 +110,7 @@ router.post('/:id/close', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/extend', async (req, res, next) => { +router.post('/:id/extend', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { newEndDate, reason } = parseBody(extendSchema, req) @@ -117,7 +118,7 @@ router.post('/:id/extend', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/cancel', async (req, res, next) => { +router.post('/:id/cancel', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { reason } = parseBody(cancelSchema, req) @@ -134,7 +135,7 @@ router.get('/:id/inspections', async (req, res, next) => { } catch (err) { next(err) } }) -router.put('/:id/inspections/:type', async (req, res, next) => { +router.put('/:id/inspections/:type', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, type: rawType } = parseParams(inspectionParamSchema, req) const type = inspectionTypeParam.parse(rawType.toUpperCase()) @@ -145,7 +146,7 @@ router.put('/:id/inspections/:type', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => { +router.patch('/:id/additional-drivers/:driverId/approval', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, driverId } = parseParams(driverParamSchema, req) const { approved, note } = parseBody(approvalSchema, req) @@ -162,7 +163,7 @@ router.get('/:id/photos', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) => { +router.post('/:id/photos', requireSubscriptionWrite, imageUpload.single('photo'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) assertImageFile(req.file, 'photo') @@ -172,7 +173,7 @@ router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) = } catch (err) { next(err) } }) -router.delete('/:id/photos/:photoId', async (req, res, next) => { +router.delete('/:id/photos/:photoId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, photoId } = parseParams(photoParamSchema, req) await photoService.deletePhoto(photoId, id, req.companyId) diff --git a/apps/api/src/modules/reviews/review.routes.ts b/apps/api/src/modules/reviews/review.routes.ts index 5a1cb92..b327b56 100644 --- a/apps/api/src/modules/reviews/review.routes.ts +++ b/apps/api/src/modules/reviews/review.routes.ts @@ -1,7 +1,8 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' +import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok } from '../../http/respond' import * as service from './review.service' @@ -9,7 +10,7 @@ import { replySchema, listQuerySchema, idParamSchema } from './review.schemas' const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { @@ -34,7 +35,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id/reply', async (req, res, next) => { +router.patch('/:id/reply', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { companyReply } = parseBody(replySchema, req) @@ -43,7 +44,7 @@ router.patch('/:id/reply', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/remind', async (req, res, next) => { +router.post('/:id/remind', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const result = await service.sendReviewReminder(id, req.companyId) diff --git a/apps/api/src/modules/site/site.repo.ts b/apps/api/src/modules/site/site.repo.ts index 521c358..8c1b879 100644 --- a/apps/api/src/modules/site/site.repo.ts +++ b/apps/api/src/modules/site/site.repo.ts @@ -2,21 +2,21 @@ import { prisma } from '../../lib/prisma' export async function findCompanyBySlug(slug: string) { return prisma.company.findFirstOrThrow({ - where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } }, + where: { slug, status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE'] } }, include: { brand: true, contractSettings: true }, }) } export async function findPublishedVehicles(companyId: string) { return prisma.vehicle.findMany({ - where: { companyId, isPublished: true }, + where: { companyId, isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' }, }) } export async function findVehicleById(vehicleId: string, companyId: string) { return prisma.vehicle.findFirstOrThrow({ - where: { id: vehicleId, companyId, isPublished: true }, + where: { id: vehicleId, companyId, isPublished: true, status: 'AVAILABLE' }, }) } diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts index a171b5f..8fc067c 100644 --- a/apps/api/src/modules/site/site.service.ts +++ b/apps/api/src/modules/site/site.service.ts @@ -46,6 +46,13 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) { } } + +function assertPublicBookingCompanyAllowed(company: any) { + if (!['ACTIVE', 'TRIALING'].includes(company.status)) { + throw new AppError('Public booking is not available for this company', 403, 'public_booking_disabled') + } +} + function generateBookingReference() { const year = new Date().getUTCFullYear() const random = Math.random().toString(36).slice(2, 8).toUpperCase() @@ -89,6 +96,7 @@ export async function getBrand(slug: string) { export async function getPublicVehicles(slug: string) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) const vehicles = await repo.findPublishedVehicles(company.id) return Promise.all( vehicles.map(async (v: any) => { @@ -100,6 +108,7 @@ export async function getPublicVehicles(slug: string) { export async function getVehicleDetail(slug: string, vehicleId: string) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) const vehicle = await repo.findVehicleById(vehicleId, company.id) const a = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId }) return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt } @@ -112,12 +121,14 @@ export async function getOffers(slug: string) { export async function getBookingOptions(slug: string) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) const insurancePolicies = await repo.findInsurancePolicies(company.id) return { insurancePolicies, contractSettings: company.contractSettings } } export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) const vehicle = await repo.findVehicleById(vehicleId, company.id) const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, @@ -143,6 +154,7 @@ export async function createBooking(slug: string, body: { selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[] }) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) const vehicle = await repo.findVehicleById(body.vehicleId, company.id) const start = new Date(body.startDate) @@ -248,6 +260,7 @@ export async function initPayment(slug: string, reservationId: string, body: { provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string }) { const company = await repo.findCompanyBySlug(slug) + assertPublicBookingCompanyAllowed(company) await assertPublicBookingAccess(reservationId, body.accessToken) const reservation = await repo.findReservationForPayment(reservationId, company.id) diff --git a/apps/api/src/modules/subscriptions/subscription.policy.ts b/apps/api/src/modules/subscriptions/subscription.policy.ts index 4dcf2a6..a3734ae 100644 --- a/apps/api/src/modules/subscriptions/subscription.policy.ts +++ b/apps/api/src/modules/subscriptions/subscription.policy.ts @@ -49,3 +49,7 @@ export function hasFullAccess(status: string): boolean { export function hasAnyAccess(status: string): boolean { return getAccessLevel(status) !== 'none' } + +export function hasWriteAccess(status: string): boolean { + return getAccessLevel(status) === 'full' +} diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index 7203854..1509722 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' +import { requireSubscriptionRead, requireSubscriptionFull } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody } from '../../http/validate' import { ok } from '../../http/respond' @@ -63,7 +64,7 @@ webhookRouter.post('/webhooks/paypal', async (req, res, next) => { // ─── PayPal capture (auth but no subscription check) ────────── -router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, res, next) => { +router.post('/capture-paypal', requireCompanyAuth, requireTenant, requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const { paypalOrderId } = parseBody(capturePaypalSchema, req) ok(res, await service.capturePaypal(req.companyId, paypalOrderId)) @@ -72,7 +73,7 @@ router.post('/capture-paypal', requireCompanyAuth, requireTenant, async (req, re // ─── Authenticated ───────────────────────────────────────────── -router.use(requireCompanyAuth, requireTenant) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/me', async (req, res, next) => { try { ok(res, await service.getSubscription(req.companyId)) } catch (err) { next(err) } @@ -90,42 +91,42 @@ router.get('/entitlement', async (req, res, next) => { try { ok(res, await service.getEntitlement(req.companyId)) } catch (err) { next(err) } }) -router.post('/trial', requireRole('OWNER'), async (req, res, next) => { +router.post('/trial', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(startTrialSchema, req) ok(res, await service.startTrial(req.companyId, body.plan, body.billingPeriod, body.currency)) } catch (err) { next(err) } }) -router.post('/checkout', requireRole('OWNER'), async (req, res, next) => { +router.post('/checkout', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(checkoutSchema, req) ok(res, await service.checkout(req.companyId, body)) } catch (err) { next(err) } }) -router.post('/reactivate', requireRole('OWNER'), async (req, res, next) => { +router.post('/reactivate', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(reactivateSchema, req) ok(res, await service.reactivate(req.companyId, body)) } catch (err) { next(err) } }) -router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => { +router.post('/change-plan', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(changePlanSchema, req) ok(res, await service.changePlan(req.companyId, body)) } catch (err) { next(err) } }) -router.post('/cancel', requireRole('OWNER'), async (req, res, next) => { +router.post('/cancel', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { const { mode, reason } = parseBody(cancelSchema, req) ok(res, await service.cancel(req.companyId, mode, reason)) } catch (err) { next(err) } }) -router.post('/resume', requireRole('OWNER'), async (req, res, next) => { +router.post('/resume', requireSubscriptionFull, requireRole('OWNER'), async (req, res, next) => { try { ok(res, await service.resume(req.companyId)) } catch (err) { next(err) } }) diff --git a/apps/api/src/modules/team/team.routes.ts b/apps/api/src/modules/team/team.routes.ts index 9aa4b33..3fffed7 100644 --- a/apps/api/src/modules/team/team.routes.ts +++ b/apps/api/src/modules/team/team.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' @@ -10,28 +10,28 @@ import { inviteSchema, roleSchema, idParamSchema } from './team.schemas' const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) -router.get('/', async (req, res, next) => { +router.get('/', requireRole('MANAGER'), async (req, res, next) => { try { ok(res, await service.getMembers(req.companyId)) } catch (err) { next(err) } }) -router.get('/stats', async (req, res, next) => { +router.get('/stats', requireRole('MANAGER'), async (req, res, next) => { try { ok(res, await service.getMemberStats(req.companyId)) } catch (err) { next(err) } }) -router.post('/invite', requireRole('OWNER'), async (req, res, next) => { +router.post('/invite', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const body = parseBody(inviteSchema, req) created(res, { data: await service.inviteEmployee(req.companyId, req.employee.id, body) }) } catch (err) { next(err) } }) -router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => { +router.patch('/:id/role', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { role } = parseBody(roleSchema, req) @@ -39,21 +39,21 @@ router.patch('/:id/role', requireRole('OWNER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/deactivate', requireRole('OWNER'), async (req, res, next) => { +router.post('/:id/deactivate', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) ok(res, await service.deactivateEmployee(req.companyId, req.employee.role, id)) } catch (err) { next(err) } }) -router.post('/:id/reactivate', requireRole('OWNER'), async (req, res, next) => { +router.post('/:id/reactivate', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) ok(res, await service.reactivateEmployee(req.companyId, req.employee.role, id)) } catch (err) { next(err) } }) -router.delete('/:id', requireRole('OWNER'), async (req, res, next) => { +router.delete('/:id', requireSubscriptionWrite, requireRole('OWNER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) ok(res, await service.removeEmployee(req.companyId, req.employee.role, id)) diff --git a/apps/api/src/modules/vehicles/vehicle.routes.ts b/apps/api/src/modules/vehicles/vehicle.routes.ts index 51717b8..0c02f5d 100644 --- a/apps/api/src/modules/vehicles/vehicle.routes.ts +++ b/apps/api/src/modules/vehicles/vehicle.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { requireTenant } from '../../middleware/requireTenant' -import { requireSubscription } from '../../middleware/requireSubscription' +import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription' import { requireRole } from '../../middleware/requireRole' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' @@ -16,7 +16,7 @@ import { const router = Router() -router.use(requireCompanyAuth, requireTenant, requireSubscription) +router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead) router.get('/', async (req, res, next) => { try { @@ -26,7 +26,7 @@ router.get('/', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/', requireRole('MANAGER'), async (req, res, next) => { +router.post('/', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const body = parseBody(vehicleSchema, req) const vehicle = await service.createVehicle(body, req.companyId) @@ -42,7 +42,7 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(vehicleSchema.partial(), req) @@ -51,7 +51,7 @@ router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) await service.deleteVehicle(id, req.companyId) @@ -59,7 +59,7 @@ router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 10), async (req, res, next) => { +router.post('/:id/photos', requireSubscriptionWrite, requireRole('MANAGER'), imageUpload.array('photos', 5), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const files = req.files as Express.Multer.File[] @@ -69,7 +69,7 @@ router.post('/:id/photos', requireRole('MANAGER'), imageUpload.array('photos', 1 } catch (err) { next(err) } }) -router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id/photos/:idx', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, idx } = parseParams(photoIdxSchema, req) const updated = await service.deletePhoto(id, req.companyId, parseInt(idx, 10)) @@ -77,7 +77,7 @@ router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) } catch (err) { next(err) } }) -router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id/status', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { status } = parseBody(statusSchema, req) @@ -85,7 +85,7 @@ router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id/publish', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const { isPublished } = parseBody(publishSchema, req) @@ -112,7 +112,7 @@ router.get('/:id/calendar', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/calendar/blocks', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(calendarBlockSchema, req) @@ -121,7 +121,7 @@ router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, nex } catch (err) { next(err) } }) -router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id/calendar/blocks/:blockId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, blockId } = parseParams(blockIdParamSchema, req) await service.deleteCalendarBlock(id, req.companyId, blockId) @@ -137,7 +137,7 @@ router.get('/:id/maintenance', async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/maintenance', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(maintenanceLogSchema, req) @@ -153,7 +153,7 @@ router.get('/:id/pricing', async (req, res, next) => { } catch (err) { next(err) } }) -router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => { +router.put('/:id/pricing', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(pricingConfigSchema, req) @@ -161,7 +161,7 @@ router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => { } catch (err) { next(err) } }) -router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) => { +router.post('/:id/pricing/rules', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id } = parseParams(idParamSchema, req) const body = parseBody(pricingRuleSchema, req) @@ -169,7 +169,7 @@ router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) } catch (err) { next(err) } }) -router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => { +router.patch('/:id/pricing/rules/:ruleId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, ruleId } = parseParams(pricingRuleParamSchema, req) const body = parseBody(pricingRuleSchema.partial(), req) @@ -177,7 +177,7 @@ router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, r } catch (err) { next(err) } }) -router.delete('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => { +router.delete('/:id/pricing/rules/:ruleId', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => { try { const { id, ruleId } = parseParams(pricingRuleParamSchema, req) ok(res, await service.deleteVehiclePricingRule(id, req.companyId, ruleId, req.employee.id)) diff --git a/apps/api/src/security/webhookIdempotency.ts b/apps/api/src/security/webhookIdempotency.ts index 6e34faa..90d3e60 100644 --- a/apps/api/src/security/webhookIdempotency.ts +++ b/apps/api/src/security/webhookIdempotency.ts @@ -2,6 +2,8 @@ import crypto from 'crypto' type WebhookStatus = 'PROCESSING' | 'PROCESSED' | 'FAILED' +const PROCESSING_RETRY_AFTER_MS = 10 * 60 * 1000 + type WebhookProcessInput = { provider: string providerEventId: string @@ -75,8 +77,15 @@ export async function processWebhookOnce({ if (existing?.payloadHash && existing.payloadHash !== hash) { throw new Error('Webhook event ID replayed with a different payload hash') } - if (existing?.status === 'PROCESSING') return { duplicate: true } - eventRecord = existing + if (existing?.status === 'PROCESSING') { + const updatedAt = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : 0 + const createdAt = existing.createdAt instanceof Date ? existing.createdAt.getTime() : 0 + const lastTouched = Math.max(updatedAt, createdAt) + if (lastTouched && Date.now() - lastTouched < PROCESSING_RETRY_AFTER_MS) return { duplicate: true } + eventRecord = existing + } else { + eventRecord = existing + } } try { @@ -90,8 +99,13 @@ export async function processWebhookOnce({ } export function getWebhookEventId(provider: 'amanpay' | 'paypal', payload: any) { - if (provider === 'paypal') { - return String(payload.id ?? payload.event_id ?? `${payload.event_type ?? 'unknown'}:${payload.resource?.id ?? 'missing'}`) + const value = provider === 'paypal' + ? payload.id ?? payload.event_id ?? null + : payload.event_id ?? payload.id ?? payload.transaction_id ?? null + + if (!value || String(value).trim() === '' || String(value).trim() === 'missing') { + throw new Error('Webhook event id is required') } - return String(payload.event_id ?? payload.id ?? payload.transaction_id ?? 'missing') + + return String(value) } diff --git a/apps/api/src/services/amanpayService.ts b/apps/api/src/services/amanpayService.ts index 40e3354..eba9137 100644 --- a/apps/api/src/services/amanpayService.ts +++ b/apps/api/src/services/amanpayService.ts @@ -85,11 +85,15 @@ export async function refundTransaction(transactionId: string, amount: number, r export function verifyWebhookSignature(rawBody: string, signature: string): boolean { if (!WEBHOOK_SECRET) return false + if (!/^[a-f0-9]{64}$/i.test(signature)) return false const expected = crypto .createHmac('sha256', WEBHOOK_SECRET) .update(rawBody) .digest('hex') - return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + const received = Buffer.from(signature, 'hex') + const expectedBuffer = Buffer.from(expected, 'hex') + if (received.length !== expectedBuffer.length) return false + return crypto.timingSafeEqual(received, expectedBuffer) } export function isConfigured(): boolean { diff --git a/apps/dashboard/src/app/api/v1/[...path]/route.ts b/apps/dashboard/src/app/api/v1/[...path]/route.ts index 19f2aa1..e97ffad 100644 --- a/apps/dashboard/src/app/api/v1/[...path]/route.ts +++ b/apps/dashboard/src/app/api/v1/[...path]/route.ts @@ -4,6 +4,17 @@ type RouteContext = { params: Promise<{ path?: string[] }> | { path?: string[] } } +const SPOOFABLE_FORWARDING_HEADERS = new Set([ + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', + 'cf-connecting-ip', + 'true-client-ip', + 'x-client-ip', +]) + const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'content-encoding', @@ -51,7 +62,8 @@ function getSetCookieHeaders(headers: Headers): string[] { function copyRequestHeaders(request: Request): Headers { const headers = new Headers() request.headers.forEach((value, key) => { - if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value) + const normalized = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(normalized) && !SPOOFABLE_FORWARDING_HEADERS.has(normalized)) headers.set(key, value) }) return headers } @@ -59,7 +71,8 @@ function copyRequestHeaders(request: Request): Headers { function copyResponseHeaders(upstream: Response): Headers { const headers = new Headers() upstream.headers.forEach((value, key) => { - if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) headers.set(key, value) + const normalized = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(normalized) && !SPOOFABLE_FORWARDING_HEADERS.has(normalized)) headers.set(key, value) }) return headers } diff --git a/apps/dashboard/src/middleware.test.ts b/apps/dashboard/src/middleware.test.ts index 3373d1b..7e25ee2 100644 --- a/apps/dashboard/src/middleware.test.ts +++ b/apps/dashboard/src/middleware.test.ts @@ -88,7 +88,7 @@ describe('dashboard middleware', () => { expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/dashboard/sign-in?redirect=%2Fdashboard%2Freservations' }) }) - it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => { + it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => { const { default: middleware } = await loadMiddleware('https://market.example.com') const response = middleware(request('http://dashboard:3001/dashboard/billing', { @@ -98,7 +98,7 @@ describe('dashboard middleware', () => { }, }) as never) - expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' }) + expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' }) }) it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => { @@ -119,7 +119,7 @@ describe('dashboard middleware', () => { const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never) - expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' }) + expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard' }) }) it('allows public dashboard auth pages without a token', async () => { diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 6f0d6c1..e0b46fc 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000' +const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard` const DASHBOARD_BASE_PATH = '/dashboard' function toDashboardAppPath(pathname: string): string { @@ -29,26 +30,12 @@ function deduplicatePublicDashboardPath(pathname: string): string | null { return normalized || DASHBOARD_BASE_PATH } -function resolveProxyUrl(req: NextRequest, pathname: string): URL { - const forwardedHost = req.headers.get('x-forwarded-host') - const forwardedProto = req.headers.get('x-forwarded-proto') - const websiteOrigin = new URL(WEBSITE_URL) - - const url = new URL(pathname, req.nextUrl.origin) - if (forwardedHost && !isInternalHost(forwardedHost)) { - url.host = forwardedHost - url.protocol = (forwardedProto ?? 'http') + ':' - if (!hasExplicitPort(forwardedHost) || isInternalAppPort(url.port)) url.port = '' - } else if (!isInternalHost(req.nextUrl.host)) { - url.host = req.nextUrl.host - url.protocol = req.nextUrl.protocol - if (!hasExplicitPort(req.nextUrl.host) || isInternalAppPort(url.port)) url.port = '' - } else { - url.host = websiteOrigin.host - url.protocol = websiteOrigin.protocol - if (!hasExplicitPort(websiteOrigin.host) || isInternalAppPort(url.port)) url.port = '' - } - return url +function resolveProxyUrl(_req: NextRequest, pathname: string): URL { + const canonicalDashboard = new URL(DASHBOARD_PUBLIC_URL) + const publicPath = pathname.startsWith(DASHBOARD_BASE_PATH) + ? pathname + : toPublicDashboardPath(pathname) + return new URL(publicPath, canonicalDashboard.origin) } function hasExplicitPort(host: string): boolean {