Files
carmanagement/docs/security_hardening_leftover.diff
2026-06-11 01:43:25 -04:00

1499 lines
71 KiB
Diff

diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md /mnt/data/car_project_leftover/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md
--- /mnt/data/car_project_leftover_before/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/car_project_leftover/SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md 2026-06-09 23:36:11.076433808 +0000
@@ -0,0 +1,255 @@
+# Security Hardening Leftover Application Report
+
+Project: RentalDriveGo / Car Management System
+Input archive: `car_management_system_hardened_applied.zip`
+Output archive: `car_management_system_leftover_applied.zip`
+Date: 2026-06-09
+
+## Executive Summary
+
+This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model.
+
+This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements.
+
+## Applied Changes
+
+### 1. Removed remaining browser-side employee token assumptions
+
+Changed files:
+
+- `apps/dashboard/src/lib/api.ts`
+- `apps/dashboard/src/components/layout/TopBar.tsx`
+- `apps/dashboard/src/components/layout/Sidebar.tsx`
+- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
+- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`
+
+What changed:
+
+- Removed dashboard use of employee auth tokens from `localStorage`.
+- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies.
+- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens.
+- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`.
+- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value.
+
+Security effect:
+
+- Reduces script-readable authentication exposure.
+- Aligns the dashboard with the intended HttpOnly session-cookie model.
+- Prevents UI code from treating a readable JWT as the authority for employee identity.
+
+### 2. Added Socket.io HttpOnly-cookie session support
+
+Changed file:
+
+- `apps/api/src/index.ts`
+
+What changed:
+
+- Added Socket.io session-token extraction from HttpOnly cookies.
+- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies.
+- Reused centralized actor-token verification.
+
+Security effect:
+
+- Real-time dashboard connections no longer require JavaScript-readable employee tokens.
+- Socket authentication now follows the same actor-token validation path used elsewhere.
+
+### 3. Added app-layer `x-middleware-subrequest` blocking
+
+Changed files:
+
+- `apps/api/src/app.ts`
+- `apps/dashboard/src/middleware.ts`
+- `apps/marketplace/src/middleware.ts`
+- `apps/admin/src/middleware.ts`
+- `apps/dashboard/src/middleware.test.ts`
+- `apps/marketplace/src/middleware.test.ts`
+
+What changed:
+
+- API now rejects requests containing `x-middleware-subrequest` before route handling.
+- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer.
+- Added/updated middleware tests for the rejection path.
+
+Security effect:
+
+- Adds defense in depth beyond reverse-proxy filtering.
+- Prevents the project from depending on a single infrastructure control for this bypass class.
+
+### 4. Hardened admin/browser fetch behavior
+
+Changed files include:
+
+- `apps/admin/src/lib/api.ts`
+- `apps/admin/src/app/dashboard/admin-users/page.tsx`
+- `apps/admin/src/app/dashboard/renters/page.tsx`
+- `apps/admin/src/app/dashboard/companies/[id]/page.tsx`
+- `apps/admin/src/app/dashboard/containers/page.tsx`
+- `apps/admin/src/app/dashboard/pricing/page.tsx`
+- `apps/admin/src/app/forgot-password/page.tsx`
+- `apps/admin/src/app/reset-password/page.tsx`
+
+What changed:
+
+- Admin API wrapper uses `credentials: 'include'`.
+- Manual admin fetch calls now include credentials where they directly call the admin API.
+- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers.
+
+Security effect:
+
+- Admin browser requests now consistently rely on the HttpOnly admin session cookie.
+- Removes misleading bearer-token scaffolding from the admin UI.
+
+### 5. Improved actor-aware rate limiting
+
+Changed files:
+
+- `apps/api/src/middleware/rateLimiter.ts`
+- `apps/api/src/app.ts`
+
+What changed:
+
+- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens.
+- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys.
+- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter.
+
+Security effect:
+
+- Authenticated traffic is limited by actor identity instead of only coarse IP data.
+- Login and admin-auth abuse get stricter protection.
+- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment.
+
+### 6. Added admin 2FA recovery-code backend workflow
+
+Changed files:
+
+- `packages/database/prisma/schema.prisma`
+- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql`
+- `apps/api/src/modules/admin/admin.repo.ts`
+- `apps/api/src/modules/admin/admin.service.ts`
+- `apps/api/src/modules/admin/admin.schemas.ts`
+- `apps/api/src/modules/admin/admin.routes.ts`
+
+What changed:
+
+- Added `AdminRecoveryCode` model.
+- Recovery codes are stored as bcrypt hashes, not plaintext.
+- TOTP enrollment now issues one-time recovery codes.
+- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA.
+- Login can consume a valid unused recovery code when TOTP is enabled.
+- Recovery-code issuance and use are audited.
+
+Security effect:
+
+- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext.
+- Preserves one-time-use semantics.
+- Adds auditability for recovery-code lifecycle events.
+
+Limitation:
+
+- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side.
+
+### 7. Strengthened static scanning for auth-token regressions
+
+Changed file:
+
+- `scripts/security-static-check.mjs`
+
+What changed:
+
+- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`.
+- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions.
+
+Security effect:
+
+- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens.
+
+### 8. Updated documentation to match the hardened model
+
+Changed files:
+
+- `apps/dashboard/README.md`
+- `memory/project_auth_architecture.md`
+- `docs/project-design/COOKIE_POLICY.md`
+- `apps/api/src/swagger/openapi.ts`
+
+What changed:
+
+- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording.
+- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts.
+- Updated cookie policy references from legacy `employee_token` wording to `employee_session`.
+
+Security effect:
+
+- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern.
+
+## Validation Performed
+
+The following checks were run successfully in the available environment:
+
+```bash
+npm run security:static
+node --check scripts/security-static-check.mjs
+# JSON parse validation for package.json and package-lock.json files
+# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml
+bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh
+# TypeScript/TSX syntax transpile check over 507 source files
+npm audit --package-lock-only --omit=dev --audit-level=critical
+```
+
+Results:
+
+- Security static check: passed.
+- Static-check script syntax: passed.
+- Package JSON validation: passed.
+- YAML validation: passed.
+- Shell syntax validation: passed.
+- TypeScript/TSX syntax transpile validation: passed.
+- Critical production dependency audit: passed.
+
+Audit note:
+
+- `npm audit --audit-level=critical` exited successfully.
+- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass.
+
+## Not Fully Verified in This Environment
+
+The following items require a real development/CI/deployment environment:
+
+- `npm ci` from a clean checkout.
+- Full workspace typecheck.
+- Full unit, integration, security, and e2e test suites.
+- Prisma client generation.
+- Applying the new database migration to a real database.
+- Database backup/restore verification.
+- Docker image build and runtime validation.
+- Container scanning with Trivy or equivalent.
+- Live reverse-proxy validation for `x-middleware-subrequest` blocking.
+- Redis-backed distributed rate-limit validation across multiple API containers.
+- Provider webhook sandbox tests.
+- Live admin 2FA recovery-code UX verification.
+
+## Remaining Launch-Gate Work
+
+These are still not things source edits can prove by themselves:
+
+1. Rotate all real production secrets.
+2. Confirm no real secrets exist in repository history or image layers.
+3. Apply and verify the new `admin_recovery_codes` migration.
+4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan.
+5. Confirm Redis and PostgreSQL are private in production.
+6. Confirm DB management tools are not publicly reachable.
+7. Confirm production containers run non-root with reduced capabilities and resource limits.
+8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads.
+9. Confirm private files are inaccessible through static routes in the deployed environment.
+10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely.
+
+## Changed Files
+
+See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff.
+
+## Final Assessment
+
+This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation.
+
+However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard.
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/admin-users/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/admin-users/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/admin-users/page.tsx 2026-06-09 19:47:54.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/admin-users/page.tsx 2026-06-09 23:28:04.035058641 +0000
@@ -33,13 +33,11 @@
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
- function getToken() { return '' }
-
async function fetchAdmins() {
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
- headers: { Authorization: `Bearer ${getToken()}` },
cache: 'no-store',
+ credentials: 'include',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
@@ -97,7 +95,8 @@
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
method: isEditing ? 'PATCH' : 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify(payload),
})
const json = await res.json()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/companies/[id]/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/companies/[id]/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/companies/[id]/page.tsx 2026-06-09 19:47:54.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/companies/[id]/page.tsx 2026-06-09 23:28:04.038741293 +0000
@@ -207,10 +207,6 @@
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
-function getToken() {
- return ''
-}
-
function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : ''
}
@@ -324,11 +320,10 @@
const [deleteConfirm, setDeleteConfirm] = useState(false)
async function fetchData() {
- const headers = { Authorization: `Bearer ${getToken()}` }
try {
const [cRes, aRes] = await Promise.all([
- fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }),
- fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }),
+ fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, { cache: 'no-store', credentials: 'include' }),
+ fetch(`${ADMIN_API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { cache: 'no-store', credentials: 'include' }),
])
const cJson = await cRes.json()
const aJson = await aRes.json()
@@ -339,7 +334,7 @@
const subId = cJson.data?.subscription?.id
if (subId) {
- const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
+ const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { cache: 'no-store', credentials: 'include' })
const eJson = await eRes.json()
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
}
@@ -365,7 +360,8 @@
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify({
company: {
name: form.company.name,
@@ -469,7 +465,8 @@
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify({ reason: subOverrideReason }),
})
const json = await res.json()
@@ -490,7 +487,8 @@
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify({ status }),
})
const json = await res.json()
@@ -508,7 +506,7 @@
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}`, {
method: 'DELETE',
- headers: { Authorization: `Bearer ${getToken()}` },
+ credentials: 'include',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Delete failed')
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/containers/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/containers/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/containers/page.tsx 2026-06-09 20:06:04.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/containers/page.tsx 2026-06-09 23:28:07.120677773 +0000
@@ -56,7 +56,7 @@
const fetchLogs = useCallback(async (lines: number) => {
setLoading(true)
try {
- const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json()
setLogs(json.data?.logs ?? '')
} catch {
@@ -125,7 +125,7 @@
const fetchContainers = useCallback(async () => {
try {
- const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders() })
+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? 'Failed to load containers.')
@@ -150,7 +150,7 @@
setProvisioning(true)
setProvisionResults(null)
try {
- const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
+ const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
setProvisionResults(json.data?.results ?? [])
@@ -171,7 +171,7 @@
action === 'remove'
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
- const res = await fetch(url, { method, headers: authHeaders() })
+ const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? `Failed to ${action} container.`)
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/pricing/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/pricing/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/pricing/page.tsx 2026-06-09 20:06:04.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/pricing/page.tsx 2026-06-09 23:28:07.124025372 +0000
@@ -314,7 +314,7 @@
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
useEffect(() => {
- fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders() })
+ fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders(), credentials: 'include' })
.then(async (r) => {
const json = await r.json()
if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
@@ -376,6 +376,7 @@
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ credentials: 'include',
body: JSON.stringify(result.payload),
})
const json = await res.json()
@@ -712,7 +713,7 @@
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
useEffect(() => {
- fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders() })
+ fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders(), credentials: 'include' })
.then(async (r) => {
const json = await r.json()
if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
@@ -795,6 +796,7 @@
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ credentials: 'include',
body: JSON.stringify(result.payload),
})
const json = await res.json()
@@ -816,6 +818,7 @@
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ credentials: 'include',
body: JSON.stringify({ isActive: !promo.isActive }),
})
const json = await res.json()
@@ -1039,6 +1042,7 @@
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
+ credentials: 'include',
body: JSON.stringify({ entries }),
})
const json = await res.json()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/renters/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/renters/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/dashboard/renters/page.tsx 2026-06-09 19:47:54.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/dashboard/renters/page.tsx 2026-06-09 23:28:04.036400425 +0000
@@ -70,13 +70,11 @@
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState<string | null>(null)
- function getToken() { return '' }
-
async function fetchRenters() {
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
- headers: { Authorization: `Bearer ${getToken()}` },
cache: 'no-store',
+ credentials: 'include',
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
@@ -105,7 +103,7 @@
const endpoint = isBlocked ? 'unblock' : 'block'
const res = await fetch(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, {
method: 'POST',
- headers: { Authorization: `Bearer ${getToken()}` },
+ credentials: 'include',
})
if (!res.ok) throw new Error('Action failed')
await fetchRenters()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/forgot-password/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/forgot-password/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/forgot-password/page.tsx 2026-06-09 19:47:54.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/forgot-password/page.tsx 2026-06-09 23:28:30.467977305 +0000
@@ -19,6 +19,7 @@
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/app/reset-password/page.tsx /mnt/data/car_project_leftover/apps/admin/src/app/reset-password/page.tsx
--- /mnt/data/car_project_leftover_before/apps/admin/src/app/reset-password/page.tsx 2026-06-09 19:47:54.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/app/reset-password/page.tsx 2026-06-09 23:28:30.468921934 +0000
@@ -35,6 +35,7 @@
const res = await fetch(`${ADMIN_API_BASE}/admin/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
body: JSON.stringify({ token, password }),
})
const json = await res.json()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/admin/src/middleware.ts /mnt/data/car_project_leftover/apps/admin/src/middleware.ts
--- /mnt/data/car_project_leftover_before/apps/admin/src/middleware.ts 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/admin/src/middleware.ts 2026-06-09 23:29:06.361808166 +0000
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+export function middleware(request: NextRequest) {
+ if (request.headers.has('x-middleware-subrequest')) {
+ return new NextResponse('Unsupported internal request header', { status: 400 })
+ }
+
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: [
+ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
+ '/(api|trpc)(.*)',
+ ],
+}
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/app.ts /mnt/data/car_project_leftover/apps/api/src/app.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/app.ts 2026-06-09 22:54:05.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/app.ts 2026-06-09 23:29:48.882746714 +0000
@@ -80,6 +80,14 @@
}
app.use(requestIdMiddleware)
+
+ app.use((req, res, next) => {
+ if (req.headers['x-middleware-subrequest']) {
+ return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 })
+ }
+ next()
+ })
+
app.use(corsMiddleware)
// Customer identity documents must never be anonymously retrievable from the
@@ -142,6 +150,7 @@
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
+ app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/index.ts /mnt/data/car_project_leftover/apps/api/src/index.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/index.ts 2026-06-09 23:18:30.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/index.ts 2026-06-09 23:28:49.446712077 +0000
@@ -1,11 +1,13 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
+import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
+import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
@@ -24,9 +26,34 @@
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
+
+function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
+ if (!cookieHeader) return null
+
+ for (const chunk of cookieHeader.split(';')) {
+ const [rawName, ...rawValue] = chunk.trim().split('=')
+ if (rawName === name) return decodeURIComponent(rawValue.join('='))
+ }
+
+ return null
+}
+
+function getSocketSessionToken(socket: Socket): string | undefined {
+ const explicitToken = socket.handshake.auth?.token
+ if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
+
+ const cookieHeader = socket.request.headers.cookie
+ return (
+ readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
+ readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
+ readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
+ undefined
+ )
+}
+
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
- const token = socket.handshake.auth?.token as string | undefined
+ const token = getSocketSessionToken(socket)
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = verifyAnyActorToken(token)
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/middleware/rateLimiter.ts /mnt/data/car_project_leftover/apps/api/src/middleware/rateLimiter.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/middleware/rateLimiter.ts 2026-06-09 20:23:21.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/middleware/rateLimiter.ts 2026-06-09 23:29:25.392962826 +0000
@@ -1,5 +1,54 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
+import { verifyAnyActorToken } from '../security/tokens'
+import { getSessionCookieName } from '../security/sessionCookies'
+
+
+const SESSION_COOKIE_NAMES = [
+ getSessionCookieName('admin'),
+ getSessionCookieName('employee'),
+ getSessionCookieName('renter'),
+]
+
+function readCookie(cookieHeader: string | undefined, name: string): string | null {
+ if (!cookieHeader) return null
+
+ for (const chunk of cookieHeader.split(';')) {
+ const [rawName, ...rawValue] = chunk.trim().split('=')
+ if (rawName === name) return decodeURIComponent(rawValue.join('='))
+ }
+
+ return null
+}
+
+function getBearerToken(req: Request): string | null {
+ const authHeader = req.headers.authorization
+ if (!authHeader?.startsWith('Bearer ')) return null
+ const token = authHeader.slice(7).trim()
+ return token || null
+}
+
+function getRequestToken(req: Request): string | null {
+ const cookieHeader = req.headers.cookie
+ for (const name of SESSION_COOKIE_NAMES) {
+ const token = readCookie(cookieHeader, name)
+ if (token) return token
+ }
+
+ return getBearerToken(req)
+}
+
+function getAuthenticatedActorKey(req: Request): string | null {
+ const token = getRequestToken(req)
+ if (!token) return null
+
+ try {
+ const payload = verifyAnyActorToken(token)
+ return `${payload.type}:${payload.sub}`
+ } catch {
+ return null
+ }
+}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
@@ -25,9 +74,10 @@
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
+ const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
- return `${ip}:${companyId || renterId}`
+ return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
@@ -48,7 +98,10 @@
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
- keyGenerator: (req) => getClientIpKey(req),
+ keyGenerator: (req) => {
+ const ip = getClientIpKey(req)
+ return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
+ },
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
@@ -62,11 +115,12 @@
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
+ const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const employeeId = (req as any).employee?.id ?? ''
const renterId = (req as any).renterId ?? ''
const adminId = (req as any).admin?.id ?? ''
- return `${ip}:${companyId}:${employeeId || renterId || adminId || 'anonymous'}`
+ return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
})
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.repo.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.repo.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.repo.ts 2026-06-09 20:16:42.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.repo.ts 2026-06-09 23:30:31.717917907 +0000
@@ -58,6 +58,28 @@
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
+
+export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
+ return prisma.$transaction(async (tx) => {
+ await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
+ await tx.adminRecoveryCode.createMany({
+ data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
+ })
+ })
+}
+
+export function listUnusedAdminRecoveryCodes(adminUserId: string) {
+ return prisma.adminRecoveryCode.findMany({
+ where: { adminUserId, usedAt: null },
+ select: { id: true, codeHash: true },
+ orderBy: { createdAt: 'asc' },
+ })
+}
+
+export function markAdminRecoveryCodeUsed(id: string) {
+ return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
+}
+
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.routes.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.routes.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.routes.ts 2026-06-09 20:20:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.routes.ts 2026-06-09 23:31:00.522967384 +0000
@@ -42,8 +42,8 @@
router.post('/auth/login', async (req, res, next) => {
try {
- const { email, password, totpCode } = parseBody(loginSchema, req)
- const result = await service.login(email, password, totpCode)
+ const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
+ const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
@@ -92,7 +92,13 @@
const result = await service.verifyTotp(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
- ok(res, { success: true, admin: result.admin })
+ ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
+ } catch (err) { next(err) }
+})
+
+router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
+ try {
+ ok(res, await service.regenerateRecoveryCodes(req.admin.id))
} catch (err) { next(err) }
})
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.schemas.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.schemas.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.schemas.ts 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.schemas.ts 2026-06-09 23:31:00.520959931 +0000
@@ -5,6 +5,7 @@
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
+ recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.service.ts /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.service.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/modules/admin/admin.service.ts 2026-06-09 20:20:34.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/modules/admin/admin.service.ts 2026-06-09 23:30:44.361011542 +0000
@@ -10,6 +10,47 @@
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
+const ADMIN_RECOVERY_CODE_COUNT = 10
+
+
+function generateRecoveryCode() {
+ const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
+ return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
+}
+
+async function issueAdminRecoveryCodes(adminId: string) {
+ const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
+ const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
+ await repo.replaceAdminRecoveryCodes(adminId, hashes)
+ await repo.createAuditLog({
+ adminUserId: adminId,
+ action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
+ resource: 'AdminUser',
+ resourceId: adminId,
+ })
+ return codes
+}
+
+async function consumeAdminRecoveryCode(adminId: string, code: string) {
+ const normalized = code.trim().toUpperCase()
+ if (!normalized) return false
+
+ const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
+ for (const candidate of codes) {
+ if (await bcrypt.compare(normalized, candidate.codeHash)) {
+ await repo.markAdminRecoveryCodeUsed(candidate.id)
+ await repo.createAuditLog({
+ adminUserId: adminId,
+ action: 'ADMIN_2FA_RECOVERY_CODE_USED',
+ resource: 'AdminUser',
+ resourceId: adminId,
+ })
+ return true
+ }
+ }
+
+ return false
+}
function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
@@ -31,7 +72,7 @@
}
}
-export async function login(email: string, password: string, totpCode?: string) {
+export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
@@ -39,8 +80,16 @@
if (!valid) return null
if (admin.totpEnabled) {
- if (!totpCode) return { totpRequired: true } as const
- if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
+ if (!totpCode && !recoveryCode) return { totpRequired: true } as const
+
+ const validTotp = totpCode
+ ? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
+ : false
+ const validRecoveryCode = !validTotp && recoveryCode
+ ? await consumeAdminRecoveryCode(admin.id, recoveryCode)
+ : false
+
+ if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
@@ -78,7 +127,15 @@
resource: 'AdminUser',
resourceId: adminId,
})
- return presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now()))
+ const recoveryCodes = await issueAdminRecoveryCodes(adminId)
+ return {
+ ...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
+ recoveryCodes,
+ }
+}
+
+export async function regenerateRecoveryCodes(adminId: string) {
+ return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
}
export async function forgotPassword(email: string) {
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/api/src/swagger/openapi.ts /mnt/data/car_project_leftover/apps/api/src/swagger/openapi.ts
--- /mnt/data/car_project_leftover_before/apps/api/src/swagger/openapi.ts 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/api/src/swagger/openapi.ts 2026-06-09 23:33:40.274784566 +0000
@@ -96,7 +96,7 @@
openapi: '3.0.3',
info: {
title: 'RentalDriveGo API',
- description: 'REST API for the RentalDriveGo car rental management platform.\n\n**Auth:** Most endpoints require a Bearer JWT. Obtain a token via `/auth/employee/login` (dashboard/admin users) or via Firebase for renters.',
+ description: 'REST API for the RentalDriveGo car rental management platform.\n\n**Auth:** Most browser endpoints use HttpOnly session cookies issued by `/auth/employee/login`, `/admin/auth/login`, or renter auth. Trusted server/mobile contexts may still use Bearer JWTs where documented.',
version: '1.0.0',
contact: { name: 'RentalDriveGo', email: 'support@rentaldrivego.com' },
},
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/README.md /mnt/data/car_project_leftover/apps/dashboard/README.md
--- /mnt/data/car_project_leftover_before/apps/dashboard/README.md 2026-06-09 20:06:04.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/README.md 2026-06-09 23:33:32.875527724 +0000
@@ -89,7 +89,7 @@
src/hooks/
useTeam.ts team management data/actions
src/lib/
- api.ts fetch wrapper and auth token handling
+ api.ts cookie-aware API fetch wrapper
preferences.ts language/theme persistence scoped per employee
dashboardPaths.ts normalizes basePath-aware routes
urls.ts resolves marketplace/admin app links
@@ -102,7 +102,7 @@
That is a deliberate fit for the app because the dashboard relies heavily on:
-- local auth state from `localStorage`
+- HttpOnly session-cookie authentication
- cookie and embedded-host redirects
- optimistic UI updates
- direct REST fetches after mount
@@ -117,7 +117,7 @@
## Auth and Session Model
-The dashboard uses employee JWTs issued by the API.
+The dashboard uses API-issued employee sessions stored in an HttpOnly cookie.
### Login flow
@@ -132,7 +132,7 @@
- language and theme query params
- embedded usage through `postMessage`
-- redirect handoff to the admin app when an admin token is returned instead of an employee token
+- redirect handoff to the admin app after the API sets the admin HttpOnly session cookie
### Protected-route middleware
@@ -221,7 +221,7 @@
- current theme: `light`, `dark`
- full in-app copy dictionaries
- persistence to cookies and `localStorage`
-- per-employee scoped preferences using the decoded JWT subject
+- per-employee scoped preferences using non-sensitive cached profile metadata
`src/lib/preferences.ts` is the key utility here. It stores both:
@@ -238,7 +238,7 @@
Browser fetch behavior:
-- reads the JWT from `localStorage`
+- never reads authentication tokens from `localStorage`
- sends authenticated requests with the HttpOnly session cookie
- defaults JSON requests to `Content-Type: application/json`
- preserves `FormData` for file uploads
@@ -252,7 +252,7 @@
### `apiFetchServer`
-Server-side helper for routes/components that already have a token and need `cache: 'no-store'`.
+Server-side helper for routes/components that explicitly receive a trusted server-side token and need `cache: 'no-store'`. Browser code should use HttpOnly cookies instead.
### Pattern used across pages
@@ -646,7 +646,7 @@
It also coordinates with sibling apps:
- `marketplaceUrl` links staff back to the public marketplace domain
-- `adminUrl` supports token handoff into the admin interface
+- `adminUrl` supports cookie-based handoff into the admin interface
- host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
## API Dependency Summary
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/app/(dashboard)/team/page.tsx /mnt/data/car_project_leftover/apps/dashboard/src/app/(dashboard)/team/page.tsx
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/app/(dashboard)/team/page.tsx 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/app/(dashboard)/team/page.tsx 2026-06-09 23:27:26.237955395 +0000
@@ -4,7 +4,7 @@
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
-import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
+import { apiFetch } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -193,18 +193,17 @@
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- if (!token) return
+ let cancelled = false
- try {
- const encoded = token.split('.')[1] ?? ''
- const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
- const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
- const payload = JSON.parse(atob(padded))
- setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
- } catch {
- setCurrentEmployeeId(null)
- }
+ apiFetch<{ employee: { id: string } }>('/auth/employee/me')
+ .then(({ employee }) => {
+ if (!cancelled) setCurrentEmployeeId(employee.id)
+ })
+ .catch(() => {
+ if (!cancelled) setCurrentEmployeeId(null)
+ })
+
+ return () => { cancelled = true }
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx /mnt/data/car_project_leftover/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx 2026-06-09 19:47:19.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx 2026-06-09 23:31:15.852597731 +0000
@@ -7,7 +7,7 @@
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
-import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
+import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
@@ -295,11 +295,16 @@
setError(null)
try {
+ const normalizedCode = totpCode.trim().toUpperCase()
+ const secondFactor = /^\d{6}$/.test(normalizedCode)
+ ? { totpCode: normalizedCode }
+ : { recoveryCode: normalizedCode }
+
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({ email, password, totpCode }),
+ body: JSON.stringify({ email, password, ...secondFactor }),
})
const adminJson = await adminRes.json()
@@ -393,13 +398,13 @@
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
<input
type="text"
- inputMode="numeric"
- pattern="[0-9]{6}"
- maxLength={6}
+ inputMode="text"
+ pattern="[0-9A-Za-z-]{6,14}"
+ maxLength={14}
required
value={totpCode}
- onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
- placeholder="000000"
+ onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
+ placeholder="000000 or XXXX-XXXX-XXXX"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/Sidebar.tsx /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/Sidebar.tsx
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/Sidebar.tsx 2026-06-09 19:47:35.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/Sidebar.tsx 2026-06-09 23:27:14.281452585 +0000
@@ -25,7 +25,7 @@
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
-import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
+import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
@@ -166,9 +166,6 @@
}, [])
useEffect(() => {
- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- if (!token) return
-
let cancelled = false
async function loadBrand() {
@@ -202,16 +199,6 @@
}, [])
useEffect(() => {
- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- if (!token) {
- setUser({
- displayName: dict.workspaceUser,
- subtitle: dict.localAuth,
- initials: 'W',
- })
- return
- }
-
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
@@ -344,7 +331,6 @@
}
function signOut() {
- localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/TopBar.tsx /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/TopBar.tsx
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/components/layout/TopBar.tsx 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/components/layout/TopBar.tsx 2026-06-09 23:27:06.243088516 +0000
@@ -4,7 +4,7 @@
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
-import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
+import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
@@ -52,11 +52,6 @@
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
- function getEmployeeToken() {
- if (typeof window === 'undefined') return null
- return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- }
-
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
@@ -65,15 +60,12 @@
return dict.titles['/']
})()
async function refreshUnreadCount() {
- if (!getEmployeeToken()) {
- setUnreadCount(0)
- return
- }
-
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
- } catch {}
+ } catch {
+ setUnreadCount(0)
+ }
}
useEffect(() => {
@@ -89,15 +81,12 @@
}, [])
useEffect(() => {
- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- if (!token) return
-
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
- auth: { token },
+ withCredentials: true,
transports: ['polling', 'websocket'],
})
@@ -121,11 +110,6 @@
useEffect(() => {
if (!showNotifs) return
- if (!getEmployeeToken()) {
- setNotifications([])
- setLoadingNotifs(false)
- return
- }
let cancelled = false
setLoadingNotifs(true)
@@ -157,12 +141,6 @@
}, [pathname])
useEffect(() => {
- const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
- if (!token) {
- setUserInitials(computeInitials(dict.workspaceUser))
- return
- }
-
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/lib/api.ts /mnt/data/car_project_leftover/apps/dashboard/src/lib/api.ts
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/lib/api.ts 2026-06-09 20:04:58.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/lib/api.ts 2026-06-09 23:26:56.191813135 +0000
@@ -3,16 +3,9 @@
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
-export const EMPLOYEE_SESSION_COOKIE = 'employee_session'
-export const EMPLOYEE_TOKEN_KEY = EMPLOYEE_SESSION_COOKIE
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
-async function getAuthToken(): Promise<string | null> {
- return null
-}
-
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
- const token = await getAuthToken()
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
@@ -23,10 +16,6 @@
headers['Content-Type'] = 'application/json'
}
- if (token) {
- headers['Authorization'] = `Bearer ${token}`
- }
-
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.test.ts /mnt/data/car_project_leftover/apps/dashboard/src/middleware.test.ts
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.test.ts 2026-06-09 20:03:48.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/middleware.test.ts 2026-06-09 23:32:41.502961148 +0000
@@ -1,15 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const nextServer = vi.hoisted(() => ({
- redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })),
- next: vi.fn(() => ({ kind: 'next' })),
-}))
+const nextServer = vi.hoisted(() => {
+ const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() }))
+ const next = vi.fn(() => ({ kind: 'next' }))
+ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
+ kind: 'response',
+ body,
+ status: init?.status,
+ })) as any
+ MockNextResponse.redirect = redirect
+ MockNextResponse.next = next
+ return { redirect, next, MockNextResponse }
+})
vi.mock('next/server', () => ({
- NextResponse: {
- redirect: nextServer.redirect,
- next: nextServer.next,
- },
+ NextResponse: nextServer.MockNextResponse,
}))
function cloneableUrl(input: string): URL {
@@ -27,6 +32,7 @@
},
headers: {
get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null),
+ has: vi.fn((name: string) => headers.has(name.toLowerCase())),
},
}
}
@@ -48,6 +54,16 @@
})
describe('dashboard middleware', () => {
+
+ it('rejects middleware subrequest headers at the app layer', async () => {
+ const { default: middleware } = await loadMiddleware()
+
+ const response = middleware(request('https://workspace.example.com/dashboard', {
+ headers: { 'x-middleware-subrequest': 'middleware:middleware' },
+ }) as never)
+
+ expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
+ })
it('deduplicates accidental repeated /dashboard prefixes before auth handling', async () => {
const { default: middleware } = await loadMiddleware()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.ts /mnt/data/car_project_leftover/apps/dashboard/src/middleware.ts
--- /mnt/data/car_project_leftover_before/apps/dashboard/src/middleware.ts 2026-06-09 20:03:48.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/dashboard/src/middleware.ts 2026-06-09 23:29:06.360416065 +0000
@@ -4,6 +4,12 @@
const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password']
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
+
+function rejectInternalSubrequest(req: NextRequest): NextResponse | null {
+ if (!req.headers.has('x-middleware-subrequest')) return null
+ return new NextResponse('Unsupported internal request header', { status: 400 })
+}
+
function dedupeDashboardPath(pathname: string): string {
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard')
}
@@ -54,6 +60,9 @@
}
export default function middleware(req: NextRequest) {
+ const rejected = rejectInternalSubrequest(req)
+ if (rejected) return rejected
+
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname)
if (dedupedPath !== req.nextUrl.pathname) {
const redirectUrl = req.nextUrl.clone()
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.test.ts /mnt/data/car_project_leftover/apps/marketplace/src/middleware.test.ts
--- /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.test.ts 2026-06-09 19:56:56.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/marketplace/src/middleware.test.ts 2026-06-09 23:32:41.504284890 +0000
@@ -1,19 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const nextServer = vi.hoisted(() => ({
- next: vi.fn((init?: { request?: { headers?: Headers } }) => ({
+const nextServer = vi.hoisted(() => {
+ const next = vi.fn((init?: { request?: { headers?: Headers } }) => ({
kind: 'next',
init,
cookies: {
set: vi.fn(),
},
- })),
-}))
+ }))
+ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
+ kind: 'response',
+ body,
+ status: init?.status,
+ })) as any
+ MockNextResponse.next = next
+ return { next, MockNextResponse }
+})
vi.mock('next/server', () => ({
- NextResponse: {
- next: nextServer.next,
- },
+ NextResponse: nextServer.MockNextResponse,
}))
function request(options: { cookies?: Record<string, string>; headers?: Record<string, string> } = {}) {
@@ -35,6 +40,17 @@
})
describe('marketplace middleware language bootstrap', () => {
+
+ it('rejects middleware subrequest headers before language handling', async () => {
+ const { middleware } = await import('./middleware')
+
+ const response = middleware(request({
+ headers: { 'x-middleware-subrequest': 'middleware:middleware' },
+ }) as never) as any
+
+ expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
+ expect(nextServer.next).not.toHaveBeenCalled()
+ })
it('does nothing when the canonical shared language cookie is valid', async () => {
const { middleware } = await import('./middleware')
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.ts /mnt/data/car_project_leftover/apps/marketplace/src/middleware.ts
--- /mnt/data/car_project_leftover_before/apps/marketplace/src/middleware.ts 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/apps/marketplace/src/middleware.ts 2026-06-09 23:29:06.361354861 +0000
@@ -4,6 +4,12 @@
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
+
+function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
+ if (!request.headers.has('x-middleware-subrequest')) return null
+ return new NextResponse('Unsupported internal request header', { status: 400 })
+}
+
function isValidLanguage(val: string | null | undefined): val is 'en' | 'fr' | 'ar' {
return val === 'en' || val === 'fr' || val === 'ar'
}
@@ -18,6 +24,9 @@
}
export function middleware(request: NextRequest) {
+ const rejected = rejectInternalSubrequest(request)
+ if (rejected) return rejected
+
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/docs/project-design/COOKIE_POLICY.md /mnt/data/car_project_leftover/docs/project-design/COOKIE_POLICY.md
--- /mnt/data/car_project_leftover_before/docs/project-design/COOKIE_POLICY.md 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/docs/project-design/COOKIE_POLICY.md 2026-06-09 23:33:22.297991839 +0000
@@ -28,13 +28,13 @@
| Field | Value |
|---|---|
-| **Name** | `employee_token` |
+| **Name** | `employee_session` |
| **Category** | Strictly necessary |
| **Duration** | 8 hours (session) |
| **Scope** | All pages (`path=/`) |
| **Third-party** | No |
-**Purpose:** This cookie is set when an employee or administrator signs in to the RentalDriveGo workspace. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page.
+**Purpose:** This cookie is set when an employee signs in to the RentalDriveGo workspace. Admins receive a separate `admin_session` cookie. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page.
**When it is set:** On a successful sign-in.
**When it is removed:** When you sign out, or automatically after 8 hours of inactivity.
@@ -148,9 +148,7 @@
## 7. Security
-The authentication cookie (`employee_token`) is signed and expires after 8 hours. It is transmitted over HTTPS in production. We recommend using the platform on trusted devices only and signing out when you are finished.
-
-Note for our technical team: the `HttpOnly` and `Secure` flags should be added to `employee_token` in a future release to further limit exposure to XSS and mixed-content attacks.
+The authentication cookies (`employee_session` and `admin_session`) are signed, HttpOnly, and expire after the configured session window. They use `Secure` in production and are not readable by JavaScript. We recommend using the platform on trusted devices only and signing out when you are finished.
---
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/memory/project_auth_architecture.md /mnt/data/car_project_leftover/memory/project_auth_architecture.md
--- /mnt/data/car_project_leftover_before/memory/project_auth_architecture.md 2026-06-09 19:40:36.000000000 +0000
+++ /mnt/data/car_project_leftover/memory/project_auth_architecture.md 2026-06-09 23:33:22.297150216 +0000
@@ -6,21 +6,21 @@
Three-tier SaaS auth system (RentalDriveGo car management monorepo):
-1. **Admins** — JWT auth (`type: 'admin'`), stored in `localStorage.admin_token` in admin app (port 3002)
-2. **Employees (Owner/Manager/Agent)** — Clerk OAuth when configured, local JWT (`type: 'employee'`) when Clerk is not configured. Token stored in `localStorage.employee_token` + `employee_token` cookie in dashboard app (port 3001)
+1. **Admins** — JWT auth (`type: 'admin'`) issued into the HttpOnly `admin_session` cookie in the admin app (port 3002)
+2. **Employees (Owner/Manager/Agent)** — Clerk OAuth when configured, local JWT (`type: 'employee'`) when Clerk is not configured. The API stores the session in the HttpOnly `employee_session` cookie; browser code may cache profile/preferences, but not auth tokens.
3. **Renters** — JWT infrastructure exists but login is currently disabled (returns 403)
**Unified login page:** `apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx`
- Shows Clerk component when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` is a real key
- Otherwise shows local form that tries employee login, then admin login
-- Admin users are redirected to admin app via `${ADMIN_URL}/auth-redirect#token=xxx`
+- Admin users are redirected after the API sets `admin_session`; URL-fragment token handoff is legacy and should not return
**Key API endpoints:**
-- `POST /api/v1/auth/employee/login` — local employee auth (email + password → JWT)
-- `POST /api/v1/admin/auth/login` — admin auth (email + password + optional TOTP → JWT)
+- `POST /api/v1/auth/employee/login` — local employee auth (email + password → HttpOnly session cookie)
+- `POST /api/v1/admin/auth/login` — admin auth (email + password + TOTP or recovery code when enabled → HttpOnly session cookie)
- `POST /api/v1/auth/company/signup` — company signup (accepts optional `password` field)
-**requireCompanyAuth middleware** — accepts local employee JWT OR Clerk session token.
+**requireCompanyAuth middleware** — accepts the `employee_session` HttpOnly cookie, and still supports trusted Bearer tokens for server/mobile/integration contexts.
**Why:** Clerk keys were placeholder values (`pk_test_your_real_publishable_key_here`), making the login page non-functional. Added local JWT auth as a fully working fallback.
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql /mnt/data/car_project_leftover/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql
--- /mnt/data/car_project_leftover_before/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/car_project_leftover/packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql 2026-06-09 23:30:23.805811712 +0000
@@ -0,0 +1,16 @@
+-- CreateTable
+CREATE TABLE "admin_recovery_codes" (
+ "id" TEXT NOT NULL,
+ "adminUserId" TEXT NOT NULL,
+ "codeHash" TEXT NOT NULL,
+ "usedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "admin_recovery_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "admin_recovery_codes_adminUserId_usedAt_idx" ON "admin_recovery_codes"("adminUserId", "usedAt");
+
+-- AddForeignKey
+ALTER TABLE "admin_recovery_codes" ADD CONSTRAINT "admin_recovery_codes_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "admin_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/packages/database/prisma/schema.prisma /mnt/data/car_project_leftover/packages/database/prisma/schema.prisma
--- /mnt/data/car_project_leftover_before/packages/database/prisma/schema.prisma 2026-06-09 23:18:30.000000000 +0000
+++ /mnt/data/car_project_leftover/packages/database/prisma/schema.prisma 2026-06-09 23:30:23.805117403 +0000
@@ -1793,8 +1793,9 @@
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
- auditLogs AuditLog[]
- permissions AdminPermission[]
+ auditLogs AuditLog[]
+ permissions AdminPermission[]
+ recoveryCodes AdminRecoveryCode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1813,6 +1814,19 @@
@@map("admin_permissions")
}
+
+model AdminRecoveryCode {
+ id String @id @default(cuid())
+ adminUserId String
+ adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
+ codeHash String
+ usedAt DateTime?
+ createdAt DateTime @default(now())
+
+ @@index([adminUserId, usedAt])
+ @@map("admin_recovery_codes")
+}
+
model AuditLog {
id String @id @default(cuid())
adminUserId String
diff -ruN '--exclude=node_modules' '--exclude=.git' '--exclude=.next' '--exclude=dist' '--exclude=build' /mnt/data/car_project_leftover_before/scripts/security-static-check.mjs /mnt/data/car_project_leftover/scripts/security-static-check.mjs
--- /mnt/data/car_project_leftover_before/scripts/security-static-check.mjs 2026-06-09 19:48:22.000000000 +0000
+++ /mnt/data/car_project_leftover/scripts/security-static-check.mjs 2026-06-09 23:30:00.568225381 +0000
@@ -17,7 +17,7 @@
walk(root)
const findings = []
-const authTokenStorage = /localStorage\.(setItem|getItem)\(['"](?:employee_token|admin_token|renter_token)['"]|document\.cookie\s*=\s*['"](?:employee_token|admin_token|renter_token)=/i
+const authTokenStorage = /localStorage\.(?:setItem|getItem|removeItem)\(\s*(?:['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)['"]|[A-Z0-9_]*TOKEN[A-Z0-9_]*)|document\.cookie\s*=\s*['"](?:employee_token|admin_token|renter_token|employee_session|admin_session|renter_session)=/i
const rawWebhookStringify = /JSON\.stringify\(req\.body\)/i
const secretAssignment = /^(JWT_SECRET|DATABASE_URL|POSTGRES_PASSWORD|SMTP_PASSWORD|REGISTRY_PASSWORD|REGISTRY_HTTP_SECRET|AMANPAY_WEBHOOK_SECRET|PAYPAL_WEBHOOK_SECRET|CLERK_SECRET_KEY|RESEND_API_KEY|MAIL_PASSWORD|FIREBASE_PRIVATE_KEY|TWILIO_AUTH_TOKEN|PAYPAL_CLIENT_SECRET|AMANPAY_SECRET_KEY|CLOUDINARY_API_SECRET|ADMIN_SEED_PASSWORD)\s*=\s*(.+)$/i