fix production issues
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
This commit is contained in:
@@ -1,435 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
interface CompanyContainer {
|
||||
id: string
|
||||
companyId: string
|
||||
dockerId: string | null
|
||||
containerName: string
|
||||
status: ContainerStatus
|
||||
port: number
|
||||
image: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
}
|
||||
const { label, className } = map[status] ?? map.ERROR
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tail, setTail] = useState(150)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
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 {
|
||||
setLogs('Failed to fetch logs.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
||||
<p className="text-xs text-zinc-400">{companyName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={tail}
|
||||
onChange={(e) => setTail(Number(e.target.value))}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={150}>Last 150 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
<option value={1000}>Last 1000 lines</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Refresh
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500">Loading…</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
||||
{logs || 'No logs available.'}
|
||||
</pre>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [provisioning, setProvisioning] = useState(false)
|
||||
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
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.')
|
||||
}
|
||||
setContainers(json.data ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
||||
/* silent — keep old data */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const id = setInterval(fetchContainers, 8000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchContainers])
|
||||
|
||||
async function provisionAll() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
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 ?? [])
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
||||
} finally {
|
||||
setProvisioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
||||
setBusy((b) => ({ ...b, [companyId]: true }))
|
||||
try {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
||||
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.`)
|
||||
}
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [companyId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = containers.filter(
|
||||
(c) =>
|
||||
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const stats = {
|
||||
running: containers.filter((c) => c.status === 'RUNNING').length,
|
||||
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
||||
error: containers.filter((c) => c.status === 'ERROR').length,
|
||||
total: containers.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-8">
|
||||
{logsFor && (
|
||||
<LogsModal
|
||||
companyId={logsFor.companyId}
|
||||
companyName={logsFor.companyName}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={provisionAll}
|
||||
disabled={provisioning}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Provisioning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
Provision All Accounts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provisionResults !== null && (
|
||||
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Provisioning complete —{' '}
|
||||
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
||||
{provisionResults.some((r) => r.status === 'error') && (
|
||||
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
||||
)}
|
||||
</p>
|
||||
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{provisionResults.map((r) => (
|
||||
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
||||
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
||||
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<p className="text-xs text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company name or container…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-zinc-500">
|
||||
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th className="px-5 py-3 font-medium">Company</th>
|
||||
<th className="px-5 py-3 font-medium">Container</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium">Port</th>
|
||||
<th className="px-5 py-3 font-medium">Image</th>
|
||||
<th className="px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{filtered.map((c) => {
|
||||
const isBusy = busy[c.companyId] ?? false
|
||||
const isRunning = c.status === 'RUNNING'
|
||||
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
||||
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
||||
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-5 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
||||
{c.errorMessage && (
|
||||
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
||||
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
||||
{c.containerName}
|
||||
{c.dockerId && (
|
||||
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
color="emerald"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'start')}
|
||||
/>
|
||||
)}
|
||||
{isRunning && (
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
color="yellow"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'stop')}
|
||||
/>
|
||||
)}
|
||||
{(isRunning || isStopped) && (
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
color="blue"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'restart')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Redeploy"
|
||||
color="purple"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'deploy')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logs"
|
||||
color="zinc"
|
||||
disabled={isBusy || !c.dockerId}
|
||||
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Remove"
|
||||
color="red"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
||||
act(c.companyId, 'remove')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-400">Out of scope</p>
|
||||
<h1 className="mt-3 text-2xl font-semibold text-zinc-100">Per-tenant containers disabled</h1>
|
||||
<p className="mt-4 text-sm leading-6 text-zinc-400">
|
||||
Company Docker container orchestration is not part of the production platform. The previous
|
||||
admin UI and Docker-socket control plane have been removed from GA because they created a
|
||||
high-privilege host trust boundary and were incomplete (no durable model or API surface).
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">
|
||||
If isolated runtimes become a commercial requirement, they must be rebuilt as a separate
|
||||
least-privilege deployment controller — never inside the business API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
color,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "node dist/index.js",
|
||||
"worker": "node dist/workers/index.js",
|
||||
"preworker:dev": "npm run build --workspace @rentaldrivego/types",
|
||||
"worker:dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/workers/index.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"pretest": "npm run build --workspace @rentaldrivego/types",
|
||||
"test": "vitest run",
|
||||
@@ -34,6 +37,7 @@
|
||||
"firebase-admin": "^10.3.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
|
||||
+67
-2
@@ -9,6 +9,7 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter }
|
||||
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
|
||||
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
|
||||
import { requestIdMiddleware } from './middleware/requestId'
|
||||
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
|
||||
|
||||
// ─── Module routes ────────────────────────────────────────────
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
@@ -132,7 +133,9 @@ export const corsOptions: CorsOptions = {
|
||||
}
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: '/health', description: 'Liveness health check' },
|
||||
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
|
||||
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
@@ -167,6 +170,7 @@ export function createApp() {
|
||||
|
||||
app.use(sanitizeForwardedHeaders)
|
||||
app.use(requestIdMiddleware)
|
||||
app.use(metricsMiddleware)
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers['x-middleware-subrequest']) {
|
||||
@@ -242,7 +246,21 @@ export function createApp() {
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
frameguard: { action: 'deny' },
|
||||
}))
|
||||
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan((tokens, req, res) => {
|
||||
const requestId = (req as any).requestId ?? '-'
|
||||
return JSON.stringify({
|
||||
level: 'info',
|
||||
msg: 'http_request',
|
||||
requestId,
|
||||
method: tokens.method(req, res),
|
||||
url: tokens.url(req, res),
|
||||
status: Number(tokens.status(req, res)),
|
||||
durationMs: Number(tokens['response-time'](req, res)),
|
||||
contentLength: tokens.res(req, res, 'content-length'),
|
||||
})
|
||||
}))
|
||||
}
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
@@ -292,6 +310,53 @@ export function createApp() {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get('/metrics', async (_req, res) => {
|
||||
try {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const [pending, published] = await Promise.all([
|
||||
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
|
||||
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
|
||||
])
|
||||
setGauge('notification_outbox_pending', pending)
|
||||
setGauge('notification_outbox_completed', published)
|
||||
} catch {
|
||||
/* leave previous gauges */
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
|
||||
res.status(200).send(renderPrometheusText())
|
||||
})
|
||||
|
||||
app.get('/ready', async (_req, res) => {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const { redis } = await import('./lib/redis')
|
||||
const { checkStorageReady } = await import('./lib/storage')
|
||||
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
checks.database = 'ok'
|
||||
} catch {
|
||||
checks.database = 'error'
|
||||
}
|
||||
try {
|
||||
const pong = await redis.ping()
|
||||
checks.redis = pong === 'PONG' ? 'ok' : 'error'
|
||||
} catch {
|
||||
checks.redis = 'error'
|
||||
}
|
||||
try {
|
||||
await checkStorageReady()
|
||||
checks.storage = 'ok'
|
||||
} catch {
|
||||
checks.storage = 'error'
|
||||
}
|
||||
const ready = Object.values(checks).every((v) => v === 'ok')
|
||||
res.status(ready ? 200 : 503).json({
|
||||
status: ready ? 'ready' : 'not_ready',
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
if (!publicDocsEnabled) return docsDisabled(_req, res)
|
||||
res.json({
|
||||
|
||||
+51
-177
@@ -1,30 +1,22 @@
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Socket } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
import { verifyAnyActorToken } from './security/tokens'
|
||||
import { getSessionCookieName } from './security/sessionCookies'
|
||||
import { processNotificationOutbox, sendNotification } from './services/notificationService'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
runPeriodEndCancellationJob,
|
||||
} from './modules/subscriptions/subscription.service'
|
||||
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
|
||||
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
|
||||
|
||||
const app = createApp()
|
||||
const app = createApp()
|
||||
const server = http.createServer(app)
|
||||
assertStorageConfiguration()
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||
})
|
||||
|
||||
|
||||
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
@@ -49,10 +41,9 @@ function getSocketSessionToken(socket: Socket): string | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate socket connections via JWT before joining user rooms
|
||||
io.use((socket, next) => {
|
||||
const token = getSocketSessionToken(socket)
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
if (!token) return next()
|
||||
try {
|
||||
const payload = verifyAnyActorToken(token)
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
@@ -64,12 +55,9 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = (socket as any).authenticatedUserId as string | undefined
|
||||
if (userId) {
|
||||
socket.join(`user:${userId}`)
|
||||
}
|
||||
if (userId) socket.join(`user:${userId}`)
|
||||
})
|
||||
|
||||
// Redis pub/sub → broadcast to connected clients
|
||||
const subscriber = redis.duplicate()
|
||||
subscriber.psubscribe('notifications:*', (err) => {
|
||||
if (err) console.error('[Redis] Subscribe error:', err)
|
||||
@@ -84,168 +72,14 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||
// Embedded jobs only when explicitly enabled (single-process local/dev).
|
||||
// Production should run `npm run worker` / Compose `api-worker` instead.
|
||||
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
|
||||
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
|
||||
startOutboxWorker()
|
||||
startScheduledJobs()
|
||||
}
|
||||
|
||||
// Daily: flag expiring/expired licenses
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
|
||||
for (const c of customers) {
|
||||
if (!c.licenseExpiry) continue
|
||||
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const expired = c.licenseExpiry <= new Date()
|
||||
const expiring = !expired && daysLeft < 90
|
||||
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
|
||||
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Hourly: expire trials that ended without payment
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
const n = await runTrialExpirationJob()
|
||||
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
|
||||
})
|
||||
|
||||
// Daily: explicit period-end cancellations. Subscription collections use the
|
||||
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
|
||||
// intentionally not scheduled.
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
const nPeriod = await runPeriodEndCancellationJob()
|
||||
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
|
||||
})
|
||||
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
const n = await runCollectionsWorker()
|
||||
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
|
||||
})
|
||||
|
||||
cron.schedule('* * * * *', async () => {
|
||||
const n = await processNotificationOutbox()
|
||||
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
|
||||
})
|
||||
|
||||
// Daily: send trial-ending reminders (3 days before trial end)
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (owner) {
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
companyId: sub.companyId,
|
||||
employeeId: owner.id,
|
||||
channels: ['IN_APP'],
|
||||
templateKey: 'subscription.trial_ending',
|
||||
templateVariables: {
|
||||
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
|
||||
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
|
||||
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
|
||||
// old overdue log is superseded and notifications stop automatically.
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Keep only the most-recent log per vehicle+type combination
|
||||
const latestByKey = new Map<string, typeof allCandidates[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
// Determine date-based urgency
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
// Determine odometer-based urgency
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
// Skip if the latest log is no longer due (owner has updated it)
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
// Build human-readable description
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
const reminderDate = now.toISOString().slice(0, 10)
|
||||
await sendNotification({
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channels: ['IN_APP'],
|
||||
sourceType: 'maintenance_log',
|
||||
sourceId: log.id,
|
||||
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────
|
||||
const PORT = Number(process.env.API_PORT ?? 4000)
|
||||
const HOST = process.env.API_HOST ?? '0.0.0.0'
|
||||
|
||||
@@ -253,4 +87,44 @@ server.listen(PORT, HOST, () => {
|
||||
console.log(`[API] Server running on ${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
console.log(`[API] ${signal} received, draining`)
|
||||
|
||||
server.close((err) => {
|
||||
if (err) console.error('[API] HTTP close error:', err.message)
|
||||
})
|
||||
|
||||
try {
|
||||
io.close()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Socket.IO close error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
try {
|
||||
await subscriber.quit()
|
||||
} catch {
|
||||
subscriber.disconnect()
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.quit()
|
||||
} catch {
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$disconnect()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Prisma disconnect error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
export { app, io }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
|
||||
|
||||
describe('idempotencyStore (memory)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.IDEMPOTENCY_STORE = 'memory'
|
||||
})
|
||||
|
||||
it('returns miss then hit for the same fingerprint', async () => {
|
||||
const key = `k-${Date.now()}`
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
|
||||
kind: 'hit',
|
||||
result: { id: 'reservation_1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('detects fingerprint conflicts', async () => {
|
||||
const key = `conflict-${Date.now()}`
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { redis } from '../lib/redis'
|
||||
|
||||
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const DEFAULT_TTL_SECONDS = 15 * 60
|
||||
|
||||
function useMemory() {
|
||||
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
export type IdempotencyHit =
|
||||
| { kind: 'miss' }
|
||||
| { kind: 'hit'; result: unknown }
|
||||
| { kind: 'conflict' }
|
||||
|
||||
export async function getIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
): Promise<IdempotencyHit> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
|
||||
if (useMemory()) {
|
||||
const cached = MEMORY.get(redisKey)
|
||||
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
|
||||
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: cached.result }
|
||||
}
|
||||
|
||||
const raw = await redis.get(redisKey)
|
||||
if (!raw) return { kind: 'miss' }
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
|
||||
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: parsed.result }
|
||||
} catch {
|
||||
return { kind: 'miss' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function setIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
ttlSeconds = DEFAULT_TTL_SECONDS,
|
||||
): Promise<void> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
const payload = JSON.stringify({ fingerprint, result })
|
||||
|
||||
if (useMemory()) {
|
||||
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
|
||||
return
|
||||
}
|
||||
|
||||
await redis.set(redisKey, payload, 'EX', ttlSeconds)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Optional S3-compatible object storage (MinIO / AWS S3).
|
||||
* Activated when FILE_STORAGE_DRIVER=s3.
|
||||
*
|
||||
* Uses the AWS SDK v3 if installed; otherwise falls back to a clear startup error.
|
||||
* Add dependency: `@aws-sdk/client-s3`
|
||||
*/
|
||||
|
||||
type S3ClientLike = {
|
||||
send: (command: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
let clientPromise: Promise<S3ClientLike> | null = null
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`${name} is required for S3 storage`)
|
||||
return value
|
||||
}
|
||||
|
||||
async function getClient(): Promise<S3ClientLike> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
// Dynamic import keeps local-only installs working without the SDK.
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
return new sdk.S3Client({
|
||||
region: process.env.S3_REGION ?? 'us-east-1',
|
||||
endpoint: process.env.S3_ENDPOINT || undefined,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
|
||||
credentials: {
|
||||
accessKeyId: required('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
}) as S3ClientLike
|
||||
} catch (err: any) {
|
||||
throw new Error(
|
||||
`FILE_STORAGE_DRIVER=s3 requires @aws-sdk/client-s3. Install it in apps/api. (${err?.message ?? err})`,
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
return clientPromise
|
||||
}
|
||||
|
||||
export async function headBucket() {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(new sdk.HeadBucketCommand({ Bucket: required('S3_BUCKET') }))
|
||||
}
|
||||
|
||||
export async function putObject(key: string, body: Buffer, contentType = 'application/octet-stream') {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(
|
||||
new sdk.PutObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getObjectBuffer(key: string): Promise<Buffer> {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
const result: any = await client.send(
|
||||
new sdk.GetObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
}),
|
||||
)
|
||||
const stream = result.Body
|
||||
if (!stream) throw new Error('Empty S3 object body')
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string) {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(
|
||||
new sdk.DeleteObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function publicObjectUrl(key: string) {
|
||||
const base = (process.env.S3_PUBLIC_BASE_URL || process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '')
|
||||
if (process.env.S3_PUBLIC_BASE_URL) return `${base}/${key.replace(/^\/+/, '')}`
|
||||
return `${base}/storage/${key.replace(/^\/+/, '')}`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
type CounterKey = string
|
||||
|
||||
const counters = new Map<CounterKey, number>()
|
||||
const latencyMs: number[] = []
|
||||
const MAX_LATENCY_SAMPLES = 2_000
|
||||
|
||||
function bump(key: CounterKey, by = 1) {
|
||||
counters.set(key, (counters.get(key) ?? 0) + by)
|
||||
}
|
||||
|
||||
export function observeHttpRequest(method: string, route: string, statusCode: number, durationMs: number) {
|
||||
const normalizedRoute = route || 'unknown'
|
||||
bump(`http_requests_total{method="${method}",route="${normalizedRoute}",status="${statusCode}"}`)
|
||||
latencyMs.push(durationMs)
|
||||
if (latencyMs.length > MAX_LATENCY_SAMPLES) latencyMs.splice(0, latencyMs.length - MAX_LATENCY_SAMPLES)
|
||||
}
|
||||
|
||||
export function observeOutboxProcessed(count: number) {
|
||||
if (count > 0) bump('notification_outbox_processed_total', count)
|
||||
}
|
||||
|
||||
export function setGauge(name: string, value: number) {
|
||||
counters.set(`gauge:${name}`, value)
|
||||
}
|
||||
|
||||
export function getGauge(name: string): number {
|
||||
return counters.get(`gauge:${name}`) ?? 0
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number) {
|
||||
if (sorted.length === 0) return 0
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
|
||||
return sorted[idx]!
|
||||
}
|
||||
|
||||
export function renderPrometheusText(): string {
|
||||
const lines: string[] = [
|
||||
'# HELP http_requests_total Total HTTP requests handled by the API',
|
||||
'# TYPE http_requests_total counter',
|
||||
]
|
||||
|
||||
for (const [key, value] of counters) {
|
||||
if (key.startsWith('gauge:')) continue
|
||||
if (key.startsWith('http_requests_total')) {
|
||||
lines.push(`${key} ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of counters) {
|
||||
if (key.startsWith('notification_outbox_processed_total')) {
|
||||
lines.push('# HELP notification_outbox_processed_total Notification outbox events completed')
|
||||
lines.push('# TYPE notification_outbox_processed_total counter')
|
||||
lines.push(`notification_outbox_processed_total ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...latencyMs].sort((a, b) => a - b)
|
||||
lines.push('# HELP http_request_duration_ms HTTP request duration percentiles (recent window)')
|
||||
lines.push('# TYPE http_request_duration_ms gauge')
|
||||
lines.push(`http_request_duration_ms{quantile="0.5"} ${percentile(sorted, 50)}`)
|
||||
lines.push(`http_request_duration_ms{quantile="0.95"} ${percentile(sorted, 95)}`)
|
||||
lines.push(`http_request_duration_ms{quantile="0.99"} ${percentile(sorted, 99)}`)
|
||||
|
||||
lines.push('# HELP notification_outbox_pending Notification outbox rows pending dispatch')
|
||||
lines.push('# TYPE notification_outbox_pending gauge')
|
||||
lines.push(`notification_outbox_pending ${getGauge('notification_outbox_pending')}`)
|
||||
|
||||
lines.push('# HELP notification_outbox_completed Notification outbox rows with status PUBLISHED (DB gauge)')
|
||||
lines.push('# TYPE notification_outbox_completed gauge')
|
||||
lines.push(`notification_outbox_completed ${getGauge('notification_outbox_completed')}`)
|
||||
|
||||
lines.push('# HELP process_uptime_seconds Process uptime')
|
||||
lines.push('# TYPE process_uptime_seconds gauge')
|
||||
lines.push(`process_uptime_seconds ${process.uptime()}`)
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const started = Date.now()
|
||||
res.on('finish', () => {
|
||||
const route = (req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path) || 'unknown'
|
||||
observeHttpRequest(req.method, route, res.statusCode, Date.now() - started)
|
||||
})
|
||||
next()
|
||||
}
|
||||
|
||||
/** Reset in-memory series (tests only). */
|
||||
export function resetMetricsForTests() {
|
||||
counters.clear()
|
||||
latencyMs.length = 0
|
||||
}
|
||||
@@ -38,11 +38,11 @@ function isWithinPath(targetPath: string, parentPath: string): boolean {
|
||||
export function assertStorageConfiguration(): string {
|
||||
const storageRoot = getStorageRoot()
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT && getStorageDriver() === 'local') {
|
||||
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === 'production' && getStorageDriver() === 'local') {
|
||||
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
|
||||
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
|
||||
if (invalidRoot) {
|
||||
@@ -60,9 +60,30 @@ export function assertStorageConfiguration(): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (getStorageDriver() === 's3') {
|
||||
for (const key of ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
|
||||
if (!process.env[key]) throw new Error(`${key} is required when FILE_STORAGE_DRIVER=s3`)
|
||||
}
|
||||
}
|
||||
|
||||
return storageRoot
|
||||
}
|
||||
|
||||
export function getStorageDriver(): 'local' | 's3' {
|
||||
return process.env.FILE_STORAGE_DRIVER === 's3' ? 's3' : 'local'
|
||||
}
|
||||
|
||||
export async function checkStorageReady(): Promise<void> {
|
||||
if (getStorageDriver() === 's3') {
|
||||
const { headBucket } = await import('./objectStorage')
|
||||
await headBucket()
|
||||
return
|
||||
}
|
||||
const root = assertStorageConfiguration()
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'private'), { recursive: true })
|
||||
}
|
||||
|
||||
function ensureStorageRoot(visibility: StorageVisibility): string {
|
||||
assertStorageConfiguration()
|
||||
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
||||
@@ -111,19 +132,24 @@ export async function uploadImage(
|
||||
publicId?: string,
|
||||
visibility: StorageVisibility = inferVisibility(folder),
|
||||
): Promise<string> {
|
||||
const safePublicId = (publicId ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || crypto.randomBytes(16).toString('hex')
|
||||
const filename = `${safePublicId}.jpg`
|
||||
|
||||
if (getStorageDriver() === 's3') {
|
||||
const objectKey = path.posix.join(visibility, folder.replace(/\\/g, '/'), filename)
|
||||
const { putObject } = await import('./objectStorage')
|
||||
await putObject(objectKey, buffer, 'image/jpeg')
|
||||
// Keep the historical public URL shape so existing clients and resolveStoredFilePath continue to work via API proxy.
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
const storageRoot = ensureStorageRoot(visibility)
|
||||
const folderPath = path.join(storageRoot, folder)
|
||||
if (!isWithinPath(folderPath, storageRoot)) {
|
||||
throw new Error('Upload path escapes storage root')
|
||||
}
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
|
||||
const filename = publicId
|
||||
? `${publicId}.jpg`
|
||||
: `${crypto.randomBytes(16).toString('hex')}.jpg`
|
||||
|
||||
fs.writeFileSync(path.join(folderPath, filename), buffer)
|
||||
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
import type { Request } from 'express'
|
||||
import { verifyAnyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
import { sharedRateLimitStore } from './redisRateLimitStore'
|
||||
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
getSessionCookieName('admin'),
|
||||
@@ -50,15 +50,18 @@ function getAuthenticatedActorKey(req: Request): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
|
||||
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
|
||||
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
|
||||
// attempts count toward the cap.
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
function withStore(options: Parameters<typeof rateLimit>[0]) {
|
||||
return rateLimit({
|
||||
...options,
|
||||
store: sharedRateLimitStore,
|
||||
})
|
||||
}
|
||||
|
||||
export const authLimiter = withStore({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
@@ -68,9 +71,8 @@ export const authLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Standard limiter for general authenticated API endpoints
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
export const apiLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 300,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
@@ -85,8 +87,7 @@ export const apiLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public carplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
export const publicLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -96,9 +97,7 @@ export const publicLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Dedicated limiter for public payment/subscription webhooks. Provider retries
|
||||
// still fit under this limit, but spray-and-pray signature attempts do not.
|
||||
export const webhookLimiter = rateLimit({
|
||||
export const webhookLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -108,8 +107,7 @@ export const webhookLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
export const adminLimiter = withStore({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -122,10 +120,7 @@ export const adminLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||
})
|
||||
|
||||
|
||||
// Applied after authentication so limits can include actor identity rather than
|
||||
// pretending every employee behind the same NAT is the same organism.
|
||||
export const actorLimiter = rateLimit({
|
||||
export const actorLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 240,
|
||||
standardHeaders: 'draft-7',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
|
||||
import { redis } from '../lib/redis'
|
||||
|
||||
/**
|
||||
* Redis-backed store for express-rate-limit.
|
||||
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
|
||||
*/
|
||||
export class RedisRateLimitStore implements Store {
|
||||
prefix: string
|
||||
windowMs = 60_000
|
||||
#local = new Map<string, { totalHits: number; resetTime: Date }>()
|
||||
|
||||
constructor(prefix = 'rl:') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
init(options: Options): void {
|
||||
this.windowMs = options.windowMs
|
||||
}
|
||||
|
||||
private useMemory() {
|
||||
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
|
||||
if (this.useMemory()) {
|
||||
const hit = this.#local.get(key)
|
||||
if (!hit) return undefined
|
||||
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
|
||||
}
|
||||
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
|
||||
if (count == null) return undefined
|
||||
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
|
||||
return { totalHits: Number(count), resetTime }
|
||||
}
|
||||
|
||||
async increment(key: string): Promise<IncrementResponse> {
|
||||
if (this.useMemory()) {
|
||||
const now = Date.now()
|
||||
const existing = this.#local.get(key)
|
||||
if (!existing || existing.resetTime.getTime() <= now) {
|
||||
const resetTime = new Date(now + this.windowMs)
|
||||
this.#local.set(key, { totalHits: 1, resetTime })
|
||||
return { totalHits: 1, resetTime }
|
||||
}
|
||||
existing.totalHits += 1
|
||||
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
|
||||
}
|
||||
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const totalHits = await redis.incr(redisKey)
|
||||
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
|
||||
const ttl = await redis.pttl(redisKey)
|
||||
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
|
||||
return { totalHits, resetTime }
|
||||
}
|
||||
|
||||
async decrement(key: string): Promise<void> {
|
||||
if (this.useMemory()) {
|
||||
const existing = this.#local.get(key)
|
||||
if (existing && existing.totalHits > 0) existing.totalHits -= 1
|
||||
return
|
||||
}
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const value = await redis.decr(redisKey)
|
||||
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
|
||||
}
|
||||
|
||||
async resetKey(key: string): Promise<void> {
|
||||
if (this.useMemory()) {
|
||||
this.#local.delete(key)
|
||||
return
|
||||
}
|
||||
await redis.del(`${this.prefix}${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const sharedRateLimitStore = new RedisRateLimitStore('rl:api:')
|
||||
@@ -165,7 +165,21 @@ describe('requireAdminRole middleware', () => {
|
||||
})
|
||||
|
||||
describe('requireFreshAdmin2FA middleware', () => {
|
||||
it('allows a 2FA-verified admin session until the session ends', () => {
|
||||
it('allows a recently 2FA-verified admin session', () => {
|
||||
const req = {
|
||||
admin: { id: 'admin_1', totpEnabled: true },
|
||||
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
|
||||
} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireFreshAdmin2FA(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks enrolled admins whose 2FA proof is stale', () => {
|
||||
const req = {
|
||||
admin: { id: 'admin_1', totpEnabled: true },
|
||||
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
|
||||
@@ -175,8 +189,13 @@ describe('requireFreshAdmin2FA middleware', () => {
|
||||
|
||||
requireFreshAdmin2FA(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'fresh_2fa_required',
|
||||
message: 'Admin 2FA verification has expired; verify again to continue',
|
||||
statusCode: 403,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
|
||||
|
||||
@@ -86,6 +86,15 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
|
||||
}
|
||||
|
||||
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
|
||||
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
|
||||
}
|
||||
|
||||
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,26 @@ describe('admin.presenter', () => {
|
||||
role: 'SUPER_ADMIN',
|
||||
passwordHash: 'hash',
|
||||
totpSecret: 'secret',
|
||||
passwordResetToken: 'reset-token',
|
||||
passwordResetExpiresAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
emailVerificationToken: 'verify-token',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
|
||||
})
|
||||
|
||||
it('wraps sessions without leaking credentials', () => {
|
||||
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
|
||||
expect(
|
||||
presentAdminSession(
|
||||
{
|
||||
id: 'admin_1',
|
||||
passwordHash: 'hash',
|
||||
totpSecret: 'secret',
|
||||
passwordResetToken: 'reset-token',
|
||||
},
|
||||
'jwt-token',
|
||||
),
|
||||
).toEqual({
|
||||
token: 'jwt-token',
|
||||
admin: { id: 'admin_1' },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
const ADMIN_SECRET_FIELDS = [
|
||||
'passwordHash',
|
||||
'totpSecret',
|
||||
'passwordResetToken',
|
||||
'passwordResetExpiresAt',
|
||||
'emailVerificationToken',
|
||||
] as const
|
||||
|
||||
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
|
||||
const { passwordHash, totpSecret, ...safe } = admin
|
||||
const safe = { ...admin }
|
||||
for (const field of ADMIN_SECRET_FIELDS) {
|
||||
delete (safe as Record<string, unknown>)[field]
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,7 @@ describe('admin.repo edge queries', () => {
|
||||
|
||||
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ passwordResetToken: hashPublicAccessToken('reset-token') },
|
||||
{ passwordResetToken: 'reset-token' },
|
||||
],
|
||||
passwordResetToken: hashPublicAccessToken('reset-token'),
|
||||
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ export function findAdminByResetToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.adminUser.findFirst({
|
||||
where: {
|
||||
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
@@ -163,6 +163,13 @@ export async function applyCompanyUpdate(
|
||||
return prisma.$transaction(async (tx: any) => {
|
||||
if (body.company) {
|
||||
const companyData = { ...body.company }
|
||||
if (typeof companyData.slug === 'string') {
|
||||
companyData.slug = companyData.slug
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50) || 'company'
|
||||
}
|
||||
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
||||
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
|
||||
? current.address as Record<string, unknown>
|
||||
|
||||
@@ -122,7 +122,13 @@ const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-
|
||||
|
||||
export const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
|
||||
.max(50)
|
||||
.optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
|
||||
@@ -4,6 +4,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
employee: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -24,26 +25,32 @@ describe('auth.employee.repo query boundaries', () => {
|
||||
})
|
||||
|
||||
it('looks up employee login emails case-insensitively and includes company context', async () => {
|
||||
prismaMock.employee.findMany.mockResolvedValue([])
|
||||
|
||||
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
|
||||
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('only sends forgot-password emails to active employees', async () => {
|
||||
prismaMock.employee.findMany.mockResolvedValue([])
|
||||
|
||||
await repo.findActiveEmployeeByEmail('agent@example.test')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: { equals: 'agent@example.test', mode: 'insensitive' },
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
|
||||
it('requires unexpired hashed reset tokens for stored-token password reset lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
|
||||
@@ -51,10 +58,7 @@ describe('auth.employee.repo query boundaries', () => {
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ passwordResetToken: hashPublicAccessToken('reset_123') },
|
||||
{ passwordResetToken: 'reset_123' },
|
||||
],
|
||||
passwordResetToken: hashPublicAccessToken('reset_123'),
|
||||
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -14,12 +14,13 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
|
||||
describe('auth.employee.repo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
|
||||
})
|
||||
|
||||
it('looks up employee login email case-insensitively', async () => {
|
||||
await findEmployeeWithCompanyByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prisma.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
@@ -27,13 +28,14 @@ describe('auth.employee.repo', () => {
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('looks up active employee reset email case-insensitively', async () => {
|
||||
await findActiveEmployeeByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prisma.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
@@ -41,6 +43,15 @@ describe('auth.employee.repo', () => {
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when multiple employees share an email', async () => {
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([{ id: 'a' }, { id: 'b' }] as never)
|
||||
await expect(findEmployeeWithCompanyByEmail('shared@example.com')).rejects.toMatchObject({
|
||||
code: 'ambiguous_employee_email',
|
||||
statusCode: 409,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,8 @@ export function findEmployeeById(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
export async function findEmployeeWithCompanyByEmail(email: string) {
|
||||
const matches = await prisma.employee.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
@@ -23,11 +23,21 @@ export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw Object.assign(new Error('Multiple employee accounts match this email. Sign in with your company context or contact support.'), {
|
||||
statusCode: 409,
|
||||
code: 'ambiguous_employee_email',
|
||||
})
|
||||
}
|
||||
|
||||
return matches[0] ?? null
|
||||
}
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
export async function findActiveEmployeeByEmail(email: string) {
|
||||
const matches = await prisma.employee.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
@@ -35,7 +45,17 @@ export function findActiveEmployeeByEmail(email: string) {
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw Object.assign(new Error('Multiple employee accounts match this email. Contact support.'), {
|
||||
statusCode: 409,
|
||||
code: 'ambiguous_employee_email',
|
||||
})
|
||||
}
|
||||
|
||||
return matches[0] ?? null
|
||||
}
|
||||
|
||||
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
|
||||
@@ -56,7 +76,7 @@ export function findEmployeeByResetToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
@@ -83,7 +103,7 @@ export function setEmailVerificationToken(id: string, tokenHash: string) {
|
||||
export function findEmployeeByVerificationToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.employee.findFirst({
|
||||
where: { OR: [{ emailVerificationToken: tokenHash }, { emailVerificationToken: token }] },
|
||||
where: { emailVerificationToken: tokenHash },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { created, ok } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import { getIdempotentResult, setIdempotentResult } from '../../lib/idempotencyStore'
|
||||
import * as service from './carplace.service'
|
||||
import {
|
||||
carplaceQuoteSchema,
|
||||
@@ -23,13 +24,6 @@ import {
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function cleanupIdempotencyCache(now = Date.now()) {
|
||||
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
|
||||
}
|
||||
|
||||
router.get('/home', async (_req, res, next) => {
|
||||
try {
|
||||
const [cities, offers, companies, search] = await Promise.all([
|
||||
@@ -81,19 +75,18 @@ router.post('/quotes', async (req, res, next) => {
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
cleanupIdempotencyCache()
|
||||
const body = parseBody(carplaceReservationSchema, req)
|
||||
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
||||
if (key) {
|
||||
const cached = idempotencyCache.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
return ok(res, cached.result)
|
||||
const cached = await getIdempotentResult('carplace:reservations', key, fingerprint)
|
||||
if (cached.kind === 'conflict') {
|
||||
return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
}
|
||||
if (cached.kind === 'hit') return ok(res, cached.result)
|
||||
}
|
||||
const result = await service.createCarplaceReservation(body)
|
||||
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
|
||||
if (key) await setIdempotentResult('carplace:reservations', key, fingerprint, result)
|
||||
created(res, result)
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
|
||||
@@ -75,11 +75,13 @@ describe('payment.service', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z'))
|
||||
delete process.env.API_URL
|
||||
process.env.DASHBOARD_URL = 'https://app.example'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.API_URL
|
||||
delete process.env.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './payment.repo'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
|
||||
export function listByCompany(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
@@ -120,6 +121,9 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
||||
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl)
|
||||
assertAllowedPaymentRedirect(body.failureUrl)
|
||||
|
||||
const amount = balanceDue
|
||||
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
||||
|
||||
@@ -36,4 +36,20 @@ describe('serializeReservationForDashboard', () => {
|
||||
|
||||
expect(result.customer?.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/customer-1/license-image')
|
||||
})
|
||||
|
||||
it('omits reviewToken from serialized dashboard payloads (S11)', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-2',
|
||||
status: 'COMPLETED',
|
||||
source: 'DASHBOARD',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'PAID',
|
||||
extras: {},
|
||||
reviewToken: 'secret-review-token',
|
||||
customer: null,
|
||||
} as any)
|
||||
|
||||
expect((result as any).reviewToken).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,8 +159,11 @@ export function serializeReservationForDashboard<T extends {
|
||||
}
|
||||
: reservation.customer
|
||||
|
||||
// S11: never expose reviewToken on API responses (capability URL secret)
|
||||
const { reviewToken: _reviewToken, ...safeReservation } = reservation as T & { reviewToken?: unknown }
|
||||
|
||||
return {
|
||||
...reservation,
|
||||
...safeReservation,
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
|
||||
|
||||
@@ -4,6 +4,13 @@ import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './review.repo'
|
||||
import * as reservationRepo from '../reservations/reservation.repo'
|
||||
|
||||
/** Strip capability secrets (reviewToken) from API-facing review payloads. */
|
||||
function presentReview<T extends { reservation?: { reviewToken?: string | null } | null }>(review: T) {
|
||||
if (!review.reservation) return review
|
||||
const { reviewToken: _t, ...reservation } = review.reservation
|
||||
return { ...review, reservation }
|
||||
}
|
||||
|
||||
export async function listReviews(
|
||||
companyId: string,
|
||||
query: { rating?: number; page: number; pageSize: number },
|
||||
@@ -14,7 +21,7 @@ export async function listReviews(
|
||||
|
||||
const [reviews, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
|
||||
return {
|
||||
data: reviews,
|
||||
data: reviews.map(presentReview),
|
||||
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
||||
}
|
||||
}
|
||||
@@ -22,17 +29,17 @@ export async function listReviews(
|
||||
export async function getReview(id: string, companyId: string) {
|
||||
const review = await repo.findById(id, companyId)
|
||||
if (!review) throw new NotFoundError('Review not found')
|
||||
return review
|
||||
return presentReview(review)
|
||||
}
|
||||
|
||||
export async function replyToReview(id: string, companyId: string, companyReply: string) {
|
||||
const review = await repo.findById(id, companyId)
|
||||
if (!review) throw new NotFoundError('Review not found')
|
||||
|
||||
return repo.updateById(id, {
|
||||
return presentReview(await repo.updateById(id, {
|
||||
companyReply,
|
||||
companyRepliedAt: new Date(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getStats(companyId: string) {
|
||||
|
||||
@@ -156,9 +156,19 @@ export async function createReservationPublicAccess(reservationId: string, token
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationPublicAccess(reservationId: string, tokenHash: string) {
|
||||
export async function findReservationPublicAccess(
|
||||
reservationId: string,
|
||||
tokenHash: string,
|
||||
options: { requireUnused?: boolean } = {},
|
||||
) {
|
||||
return (prisma as any).reservationPublicAccess.findFirst({
|
||||
where: { reservationId, tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
where: {
|
||||
reservationId,
|
||||
tokenHash,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
...(options.requireUnused ? { usedAt: null } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,3 +178,17 @@ export async function markReservationPublicAccessUsed(id: string) {
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/** Atomically burn a public access row. Returns false if already used/revoked/expired. */
|
||||
export async function consumeReservationPublicAccess(id: string): Promise<boolean> {
|
||||
const result = await (prisma as any).reservationPublicAccess.updateMany({
|
||||
where: {
|
||||
id,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
return result.count === 1
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('./site.repo', () => ({
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
consumeReservationPublicAccess: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
@@ -88,6 +89,7 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never)
|
||||
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never)
|
||||
|
||||
@@ -10,40 +10,15 @@ import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import { presentBrand, presentPublicBooking } from './site.presenter'
|
||||
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
|
||||
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
allowedHosts.add('localhost:3000')
|
||||
allowedHosts.add('localhost:4000')
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
|
||||
function assertCompanyPaymentRedirect(urlValue: string, company: any) {
|
||||
const brand = company.brand as any
|
||||
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
|
||||
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
assertAllowedPaymentRedirect(urlValue, {
|
||||
customDomain: brand?.customDomain,
|
||||
customDomainVerified: brand?.customDomainVerified,
|
||||
subdomain: brand?.subdomain,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -243,16 +218,28 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
|
||||
async function assertPublicBookingAccess(
|
||||
reservationId: string,
|
||||
token: string | undefined,
|
||||
options: { consume?: boolean } = {},
|
||||
) {
|
||||
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
|
||||
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
|
||||
const access = await repo.findReservationPublicAccess(
|
||||
reservationId,
|
||||
hashPublicAccessToken(token),
|
||||
{ requireUnused: Boolean(options.consume) },
|
||||
)
|
||||
if (!access) throw new AppError('Booking not found', 404, 'not_found')
|
||||
await repo.markReservationPublicAccessUsed(access.id)
|
||||
if (options.consume) {
|
||||
const burned = await repo.consumeReservationPublicAccess(access.id)
|
||||
if (!burned) throw new AppError('Booking not found', 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
await assertPublicBookingAccess(reservationId, accessToken)
|
||||
// Reads do not burn the token (S12); payment init consumes it once.
|
||||
await assertPublicBookingAccess(reservationId, accessToken, { consume: false })
|
||||
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
|
||||
}
|
||||
|
||||
@@ -261,7 +248,7 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
assertPublicBookingCompanyAllowed(company)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken, { consume: true })
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
@@ -279,8 +266,8 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl, company)
|
||||
assertAllowedPaymentRedirect(body.failureUrl, company)
|
||||
assertCompanyPaymentRedirect(body.successUrl, company)
|
||||
assertCompanyPaymentRedirect(body.failureUrl, company)
|
||||
|
||||
const currency = body.currency ?? 'MAD'
|
||||
const amount = reservation.totalAmount
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('./site.repo', () => ({
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
consumeReservationPublicAccess: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
@@ -110,6 +111,7 @@ beforeEach(() => {
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -296,5 +298,13 @@ describe('initPayment — payment guard paths', () => {
|
||||
const result = await initPayment(SLUG, 'r-1', payBody)
|
||||
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
|
||||
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
|
||||
expect(repo.consumeReservationPublicAccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects payment when public access token was already consumed (S12)', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue(null as never)
|
||||
|
||||
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,11 +82,13 @@ describe('subscription.service operational edges', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
|
||||
process.env.API_URL = 'https://api.example.test'
|
||||
process.env.DASHBOARD_URL = 'https://app.example.test'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.API_URL
|
||||
delete process.env.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './subscription.repo'
|
||||
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
import {
|
||||
createCanonicalStripeCheckoutInvoice,
|
||||
finalizeCanonicalOnlinePayment,
|
||||
@@ -279,6 +280,9 @@ export async function checkout(companyId: string, body: {
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
||||
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl)
|
||||
assertAllowedPaymentRedirect(body.failureUrl)
|
||||
|
||||
const orderId = `sub-${companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PLAN_FEATURES } from '@rentaldrivego/types'
|
||||
import { PLAN_FEATURES, PLAN_ENTITLEMENTS, getVehicleLimit } from '@rentaldrivego/types'
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||
@@ -351,12 +351,6 @@ const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV',
|
||||
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
|
||||
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
|
||||
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
|
||||
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
|
||||
STARTER: 25,
|
||||
GROWTH: 75,
|
||||
PRO: 150,
|
||||
ENTERPRISE: null,
|
||||
}
|
||||
|
||||
function listTextVariants(value: string) {
|
||||
const lower = value.toLowerCase()
|
||||
@@ -390,10 +384,14 @@ async function getVehicleLimitForCompany(companyId: string) {
|
||||
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
|
||||
if (persistedLimit !== undefined) return persistedLimit
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(PLAN_ENTITLEMENTS, plan)) {
|
||||
return getVehicleLimit(plan)
|
||||
}
|
||||
|
||||
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
|
||||
if (fallbackLimit !== undefined) return fallbackLimit
|
||||
|
||||
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
async function assertCanAddActiveFleetVehicle(companyId: string) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AppError } from '../http/errors'
|
||||
import { assertAllowedPaymentRedirect } from './paymentRedirects'
|
||||
|
||||
describe('assertAllowedPaymentRedirect', () => {
|
||||
const previous = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DASHBOARD_URL: process.env.DASHBOARD_URL,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = previous.NODE_ENV
|
||||
process.env.DASHBOARD_URL = previous.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('allows configured first-party hosts', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
|
||||
expect(() => assertAllowedPaymentRedirect('https://app.rentaldrivego.test/billing/ok')).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unknown hosts', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
|
||||
expect(() => assertAllowedPaymentRedirect('https://evil.example/phish')).toThrow(AppError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppError } from '../http/errors'
|
||||
|
||||
/**
|
||||
* Allowlist payment provider return URLs to first-party hosts only.
|
||||
* Prevents authenticated checkout from being used as an open-redirect / phishing vector.
|
||||
*/
|
||||
export function assertAllowedPaymentRedirect(
|
||||
urlValue: string,
|
||||
options: {
|
||||
customDomain?: string | null
|
||||
customDomainVerified?: boolean | null
|
||||
subdomain?: string | null
|
||||
} = {},
|
||||
) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
for (const host of [
|
||||
'localhost:3000',
|
||||
'localhost:3001',
|
||||
'localhost:3002',
|
||||
'localhost:3004',
|
||||
'localhost:4000',
|
||||
'127.0.0.1:3000',
|
||||
'127.0.0.1:3001',
|
||||
'127.0.0.1:3002',
|
||||
'127.0.0.1:3004',
|
||||
'127.0.0.1:4000',
|
||||
]) {
|
||||
allowedHosts.add(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of [
|
||||
process.env.CARPLACE_URL,
|
||||
process.env.DASHBOARD_URL,
|
||||
process.env.HOMEPAGE_URL,
|
||||
process.env.ADMIN_URL,
|
||||
process.env.API_URL,
|
||||
process.env.NEXT_PUBLIC_CARPLACE_URL,
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL,
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL,
|
||||
process.env.SITE_ORIGIN,
|
||||
]) {
|
||||
if (!value) continue
|
||||
try {
|
||||
allowedHosts.add(new URL(value).host)
|
||||
} catch {
|
||||
/* ignore invalid env */
|
||||
}
|
||||
}
|
||||
|
||||
if (options.customDomain && options.customDomainVerified) {
|
||||
allowedHosts.add(options.customDomain)
|
||||
}
|
||||
if (options.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${options.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Allow only same-app relative paths for post-login redirects.
|
||||
* Rejects protocol-relative URLs (//evil), absolute URLs, and backslash tricks.
|
||||
*/
|
||||
export function resolveSafeAppRedirect(candidate: string | null | undefined, fallback: string): string {
|
||||
const value = (candidate ?? '').trim()
|
||||
if (!value) return fallback
|
||||
if (!value.startsWith('/')) return fallback
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback
|
||||
if (value.includes('://')) return fallback
|
||||
if (/[\r\n\\]/.test(value)) return fallback
|
||||
return value
|
||||
}
|
||||
@@ -1,136 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ContainerFeatureDisabledError, buildComposeYaml, listCompanyContainers, provisionCompanyContainer } from './containerService'
|
||||
|
||||
const execFileMock = vi.fn()
|
||||
const fsState = new Map<string, string>()
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: (file: string, args: string[], cb: (error: any, stdout: string, stderr: string) => void) => {
|
||||
const result = execFileMock(file, args) as { stdout?: string; stderr?: string } | undefined
|
||||
cb(null, result?.stdout ?? '', result?.stderr ?? '')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn(async (file: string) => {
|
||||
if (!fsState.has(file)) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}),
|
||||
readFile: vi.fn(async (file: string) => fsState.get(file) ?? '{}'),
|
||||
writeFile: vi.fn(async (file: string, contents: string) => { fsState.set(file, contents) }),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
companyContainer: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
createCompanyContainer,
|
||||
getContainerLogs,
|
||||
restartCompanyContainer,
|
||||
startCompanyContainer,
|
||||
stopCompanyContainer,
|
||||
syncContainerStatuses,
|
||||
} from './containerService'
|
||||
|
||||
describe('containerService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fsState.clear()
|
||||
fsState.set('/opt/companies/docker-compose.companies.yml', 'services: {}')
|
||||
fsState.set('/opt/companies/companies-services.json', '{}')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('allocates the first configured port, writes compose service metadata, starts the service, and stores docker id', async () => {
|
||||
vi.mocked(prisma.companyContainer.findFirst).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.companyContainer.create).mockResolvedValue({ id: 'container_row_1' } as never)
|
||||
|
||||
execFileMock.mockImplementation((file: string, args: string[]) => {
|
||||
// The child_process mock calls back with empty stdout. This spy records the command shape.
|
||||
return { file, args }
|
||||
})
|
||||
|
||||
await createCompanyContainer({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars' })
|
||||
|
||||
expect(prisma.companyContainer.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
containerName: 'rdg-company-atlas-cars',
|
||||
status: 'CREATING',
|
||||
port: 5100,
|
||||
}),
|
||||
})
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/opt/companies/companies-services.json',
|
||||
expect.stringContaining('"atlas-cars"'),
|
||||
'utf8',
|
||||
)
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/opt/companies/docker-compose.companies.yml',
|
||||
expect.stringContaining('container_name: "rdg-company-atlas-cars"'),
|
||||
'utf8',
|
||||
)
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['version'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({
|
||||
where: { id: 'container_row_1' },
|
||||
data: { dockerId: null, status: 'RUNNING' },
|
||||
describe('containerService (disabled)', () => {
|
||||
it('fails closed for all orchestration entry points', async () => {
|
||||
await expect(listCompanyContainers()).rejects.toBeInstanceOf(ContainerFeatureDisabledError)
|
||||
await expect(provisionCompanyContainer('company_1')).rejects.toMatchObject({
|
||||
code: 'container_feature_disabled',
|
||||
statusCode: 501,
|
||||
})
|
||||
expect(() => buildComposeYaml([])).toThrow(ContainerFeatureDisabledError)
|
||||
})
|
||||
|
||||
it('starts, stops, and restarts an existing company service with status transitions', async () => {
|
||||
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({
|
||||
companyId: 'company_1',
|
||||
company: { slug: 'atlas-cars' },
|
||||
} as never)
|
||||
|
||||
await startCompanyContainer('company_1')
|
||||
await stopCompanyContainer('company_1')
|
||||
await restartCompanyContainer('company_1')
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'stop', 'company-atlas-cars'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'restart', 'company-atlas-cars'])
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RESTARTING', errorMessage: null } })
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RUNNING', errorMessage: null } })
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'STOPPED', errorMessage: null } })
|
||||
})
|
||||
|
||||
it('returns an empty log string when docker log retrieval fails', async () => {
|
||||
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({ company: { slug: 'atlas-cars' } } as never)
|
||||
execFileMock.mockImplementationOnce(() => { throw new Error('docker unavailable') })
|
||||
|
||||
await expect(getContainerLogs('company_1', 50)).resolves.toBe('')
|
||||
})
|
||||
|
||||
it('loads container records and leaves stored statuses unchanged when docker status sync cannot complete', async () => {
|
||||
vi.mocked(prisma.companyContainer.findMany).mockResolvedValue([
|
||||
{ id: 'row_1', status: 'STOPPED', company: { slug: 'atlas-cars' } },
|
||||
{ id: 'row_2', status: 'RUNNING', company: { slug: 'sahara-rentals' } },
|
||||
] as never)
|
||||
|
||||
execFileMock.mockImplementation(() => { throw new Error('Docker daemon is unavailable') })
|
||||
|
||||
await expect(syncContainerStatuses()).resolves.toBeUndefined()
|
||||
|
||||
expect(prisma.companyContainer.findMany).toHaveBeenCalledWith({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
expect(prisma.companyContainer.update).not.toHaveBeenCalled()
|
||||
})})
|
||||
})
|
||||
|
||||
@@ -1,469 +1,58 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { prisma as _prisma } from '../lib/prisma'
|
||||
/**
|
||||
* Per-tenant Docker container orchestration is OUT OF PRODUCTION SCOPE.
|
||||
* This module is intentionally disabled so the business API never talks to
|
||||
* a Docker socket or generates Compose YAML from tenant input.
|
||||
*
|
||||
* See docs/ADR-001-disable-per-tenant-containers.md
|
||||
*/
|
||||
|
||||
const db = _prisma as any
|
||||
export class ContainerFeatureDisabledError extends Error {
|
||||
statusCode = 501
|
||||
code = 'container_feature_disabled'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
let composeCommand: 'docker-compose' | 'docker' | null = null
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
|
||||
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
|
||||
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
|
||||
|
||||
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
|
||||
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
|
||||
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
|
||||
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
|
||||
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServiceDef {
|
||||
companyId: string
|
||||
slug: string
|
||||
image: string
|
||||
port: number
|
||||
}
|
||||
|
||||
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
|
||||
|
||||
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function serviceName(slug: string) {
|
||||
return `company-${slug}`
|
||||
}
|
||||
|
||||
function containerName(slug: string) {
|
||||
return `rdg-company-${slug}`
|
||||
}
|
||||
|
||||
async function ensureDir(): Promise<void> {
|
||||
await fs.mkdir(COMPOSE_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
async function readServices(): Promise<ServicesMap> {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return {}
|
||||
constructor(message = 'Per-tenant container management is disabled and out of production scope') {
|
||||
super(message)
|
||||
this.name = 'ContainerFeatureDisabledError'
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServices(services: ServicesMap): Promise<void> {
|
||||
await ensureDir()
|
||||
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
|
||||
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
|
||||
function disabled(): never {
|
||||
throw new ContainerFeatureDisabledError()
|
||||
}
|
||||
|
||||
async function composeFilesExist(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(COMPOSE_FILE)
|
||||
await fs.access(SERVICES_FILE)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
export async function provisionCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
records.map((record: any) => [
|
||||
serviceName(record.company.slug),
|
||||
{
|
||||
companyId: record.companyId,
|
||||
slug: record.company.slug,
|
||||
image: record.image,
|
||||
port: record.port,
|
||||
},
|
||||
]),
|
||||
)
|
||||
export async function startCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function ensureComposeFiles(): Promise<void> {
|
||||
if (await composeFilesExist()) return
|
||||
|
||||
const services = await rebuildServicesFromDatabase()
|
||||
await writeServices(services)
|
||||
export async function stopCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function buildComposeYaml(services: ServicesMap): string {
|
||||
const lines: string[] = ['services:']
|
||||
|
||||
for (const [name, svc] of Object.entries(services)) {
|
||||
lines.push(` ${name}:`)
|
||||
lines.push(` image: "${svc.image}"`)
|
||||
lines.push(` container_name: "${containerName(svc.slug)}"`)
|
||||
lines.push(` restart: unless-stopped`)
|
||||
lines.push(` environment:`)
|
||||
lines.push(` COMPANY_ID: "${svc.companyId}"`)
|
||||
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
|
||||
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` PORT: "3000"`)
|
||||
lines.push(` NODE_ENV: "production"`)
|
||||
lines.push(` ports:`)
|
||||
lines.push(` - "${svc.port}:3000"`)
|
||||
lines.push(` networks:`)
|
||||
lines.push(` - ${CONTAINER_NETWORK}`)
|
||||
lines.push(` labels:`)
|
||||
lines.push(` rdg.managed: "true"`)
|
||||
lines.push(` rdg.company.id: "${svc.companyId}"`)
|
||||
lines.push(` rdg.company.slug: "${svc.slug}"`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push('networks:')
|
||||
lines.push(` ${CONTAINER_NETWORK}:`)
|
||||
lines.push(` external: true`)
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
export async function restartCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
|
||||
if (composeCommand) return composeCommand
|
||||
|
||||
try {
|
||||
await execFileAsync('docker-compose', ['version'])
|
||||
composeCommand = 'docker-compose'
|
||||
return composeCommand
|
||||
} catch {
|
||||
try {
|
||||
await execFileAsync('docker', ['compose', 'version'])
|
||||
composeCommand = 'docker'
|
||||
return composeCommand
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
}
|
||||
export async function removeCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
await ensureComposeFiles()
|
||||
const command = await resolveComposeCommand()
|
||||
if (command === 'docker-compose') {
|
||||
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
|
||||
}
|
||||
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
export async function getCompanyContainerLogs(_companyId: string, _tail = 100): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function dockerUnavailableError(message: string, details?: string) {
|
||||
const err = Object.assign(new Error(message), {
|
||||
statusCode: 503,
|
||||
code: 'docker_unavailable',
|
||||
})
|
||||
|
||||
if (details) {
|
||||
;(err as Error & { details?: string }).details = details
|
||||
}
|
||||
|
||||
return err
|
||||
export async function listCompanyContainers(): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function mapDockerError(err: unknown) {
|
||||
if (err && typeof err === 'object') {
|
||||
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
|
||||
const stderr = error.stderr?.trim()
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker') {
|
||||
return dockerUnavailableError(
|
||||
'Docker CLI is not available to the API service.',
|
||||
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
|
||||
)
|
||||
}
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes("docker: 'compose' is not a docker command.") ||
|
||||
stderr?.includes("unknown shorthand flag: 'f' in -f")
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes('Cannot connect to the Docker daemon') ||
|
||||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
|
||||
stderr?.includes('error during connect')
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker daemon is not reachable from the API service.',
|
||||
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
|
||||
)
|
||||
}
|
||||
|
||||
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
|
||||
if (typeof error.code === 'number' && error.code !== 0) {
|
||||
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
|
||||
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
export async function provisionAllPending(): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function setContainerError(companyId: string, message: string): Promise<void> {
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: 'ERROR', errorMessage: message },
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function runContainerAction(
|
||||
companyId: string,
|
||||
slug: string,
|
||||
command: 'start' | 'stop' | 'restart',
|
||||
successStatus: Exclude<ServiceStatus, 'ERROR'>,
|
||||
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
|
||||
): Promise<void> {
|
||||
if (preStatus) {
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
|
||||
}
|
||||
|
||||
try {
|
||||
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
|
||||
const args = command === 'start'
|
||||
? ['up', '-d', '--no-recreate', serviceName(slug)]
|
||||
: [command, serviceName(slug)]
|
||||
await compose(...args)
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: successStatus, errorMessage: null },
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `Failed to ${command} container`
|
||||
await setContainerError(companyId, message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
const last = await db.companyContainer.findFirst({
|
||||
orderBy: { port: 'desc' },
|
||||
select: { port: true },
|
||||
})
|
||||
const next = last ? last.port + 1 : PORT_RANGE_START
|
||||
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
|
||||
return next
|
||||
}
|
||||
|
||||
async function getDockerServiceId(slug: string): Promise<string | null> {
|
||||
try {
|
||||
const name = serviceName(slug)
|
||||
const { stdout } = await compose('ps', '--format', 'json', name)
|
||||
const line = stdout.trim().split('\n')[0]
|
||||
if (!line) return null
|
||||
const info = JSON.parse(line)
|
||||
return info.ID ?? info.Id ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Maps docker compose State strings to our DB enum
|
||||
function mapDockerState(state: string): ServiceStatus {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running': return 'RUNNING'
|
||||
case 'restarting': return 'RESTARTING'
|
||||
case 'exited':
|
||||
case 'created':
|
||||
case 'paused': return 'STOPPED'
|
||||
default: return 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
const port = await allocatePort()
|
||||
|
||||
const record = await db.companyContainer.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
containerName: containerName(company.slug),
|
||||
status: 'CREATING',
|
||||
port,
|
||||
image: DASHBOARD_IMAGE,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const services = await readServices()
|
||||
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
|
||||
await writeServices(services)
|
||||
|
||||
await compose('up', '-d', '--no-recreate', name)
|
||||
|
||||
const dockerId = await getDockerServiceId(company.slug)
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: { dockerId, status: 'RUNNING' },
|
||||
})
|
||||
} catch (err) {
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ERROR',
|
||||
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
|
||||
}
|
||||
|
||||
export async function stopCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
|
||||
}
|
||||
|
||||
export async function restartCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
|
||||
}
|
||||
|
||||
export async function removeCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
const name = serviceName(record.company.slug)
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
|
||||
|
||||
try {
|
||||
await compose('stop', name)
|
||||
} catch { /* already stopped */ }
|
||||
|
||||
try {
|
||||
await compose('rm', '-f', name)
|
||||
} catch { /* already removed */ }
|
||||
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await db.companyContainer.delete({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function redeployCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
|
||||
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
|
||||
if (existing) {
|
||||
try { await compose('stop', name) } catch { /* ok */ }
|
||||
try { await compose('rm', '-f', name) } catch { /* ok */ }
|
||||
await db.companyContainer.delete({ where: { companyId: company.id } })
|
||||
}
|
||||
|
||||
// Remove from compose file too, then recreate
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await createCompanyContainer(company)
|
||||
}
|
||||
|
||||
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
const { stdout } = await compose(
|
||||
'logs',
|
||||
'--no-log-prefix',
|
||||
`--tail=${tail}`,
|
||||
serviceName(record.company.slug),
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncContainerStatuses(): Promise<void> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
// Get all compose service states in one shot
|
||||
const { stdout } = await compose('ps', '--format', 'json')
|
||||
const rows = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
})
|
||||
.filter(Boolean) as Array<{ Service: string; State: string }>
|
||||
|
||||
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
|
||||
|
||||
await Promise.allSettled(
|
||||
records.map(async (rec: any) => {
|
||||
const name = serviceName(rec.company.slug)
|
||||
const dockerState = stateByService[name]
|
||||
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
|
||||
|
||||
if (rec.status !== status) {
|
||||
await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// Docker daemon unreachable — leave statuses as-is
|
||||
}
|
||||
/** @deprecated Kept only so accidental imports fail closed. */
|
||||
export function buildComposeYaml(_services: unknown): never {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Resend } from 'resend'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||
import {
|
||||
renderLocalizedEmailHtml,
|
||||
@@ -708,18 +709,55 @@ async function deliveryEmail(recipient: any) {
|
||||
?? null
|
||||
}
|
||||
|
||||
export async function processNotificationOutbox(limit = 50) {
|
||||
export async function processNotificationOutbox(limit = 50, workerId = `worker:${process.pid}`) {
|
||||
const leaseExpiredBefore = new Date(Date.now() - 2 * 60 * 1000)
|
||||
const now = new Date()
|
||||
const candidates = await prisma.notificationOutbox.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
AND: [
|
||||
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
|
||||
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.max(1, Math.min(limit, 200)),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const claimedIds: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
const claimed = await prisma.notificationOutbox.updateMany({
|
||||
where: {
|
||||
id: candidate.id,
|
||||
status: 'PENDING',
|
||||
AND: [
|
||||
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
|
||||
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedBy: workerId,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
})
|
||||
if (claimed.count === 1) claimedIds.push(candidate.id)
|
||||
}
|
||||
|
||||
if (claimedIds.length === 0) return 0
|
||||
|
||||
const entries = await prisma.notificationOutbox.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
where: { id: { in: claimedIds } },
|
||||
include: {
|
||||
notificationEvent: {
|
||||
include: {
|
||||
recipients: {
|
||||
include: {
|
||||
employee: { select: { email: true } },
|
||||
renter: { select: { email: true } },
|
||||
employee: { select: { id: true, email: true } },
|
||||
renter: { select: { id: true, email: true } },
|
||||
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
|
||||
adminUser: { select: { email: true, isActive: true } },
|
||||
adminUser: { select: { id: true, email: true, isActive: true } },
|
||||
deliveries: true,
|
||||
},
|
||||
},
|
||||
@@ -727,7 +765,6 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.max(1, Math.min(limit, 200)),
|
||||
})
|
||||
|
||||
let processed = 0
|
||||
@@ -740,7 +777,10 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
|
||||
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
|
||||
})
|
||||
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
|
||||
})
|
||||
processed += 1
|
||||
continue
|
||||
}
|
||||
@@ -756,6 +796,20 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
where: { id: delivery.id },
|
||||
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
|
||||
})
|
||||
const userId = recipient.employeeId ?? recipient.renterId ?? recipient.adminUserId
|
||||
if (userId) {
|
||||
await redis.publish(
|
||||
`notifications:${userId}`,
|
||||
JSON.stringify({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: event.data,
|
||||
createdAt: event.createdAt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else if (delivery.channel === 'EMAIL') {
|
||||
const to = await deliveryEmail(recipient)
|
||||
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
|
||||
@@ -810,8 +864,21 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
},
|
||||
})
|
||||
if (remaining === 0) {
|
||||
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
|
||||
})
|
||||
processed += 1
|
||||
} else {
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
lockedAt: null,
|
||||
lockedBy: null,
|
||||
availableAt: new Date(Date.now() + 30_000),
|
||||
failureReason: 'Pending deliveries remain',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return processed
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../lib/prisma', () => ({
|
||||
employee: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
company: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
@@ -19,7 +20,8 @@ vi.mock('./notificationService', () => ({
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from './notificationService'
|
||||
import { inviteEmployee } from './teamService'
|
||||
import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
import { inviteEmployee, listEmployees } from './teamService'
|
||||
|
||||
describe('teamService inviteEmployee', () => {
|
||||
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||
@@ -37,11 +39,19 @@ describe('teamService inviteEmployee', () => {
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any)
|
||||
vi.mocked(prisma.employee.create).mockResolvedValue({
|
||||
id: 'emp_1',
|
||||
clerkUserId: 'local_member_uuid-123',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
preferredLanguage: 'en',
|
||||
emailVerified: null,
|
||||
passwordHash: null,
|
||||
passwordResetToken: 'hashed',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
} as any)
|
||||
})
|
||||
|
||||
@@ -51,20 +61,56 @@ describe('teamService inviteEmployee', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('builds invite reset links under the dashboard base path', async () => {
|
||||
await inviteEmployee('company_1', 'owner_1', {
|
||||
it('stores hashed invite tokens and never returns secrets in the API payload', async () => {
|
||||
const rawToken = Buffer.from('token-123').toString('hex')
|
||||
const result = await inviteEmployee('company_1', 'owner_1', {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
email: 'Aya@Example.com',
|
||||
role: 'AGENT',
|
||||
})
|
||||
|
||||
expect(prisma.employee.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: 'aya@example.com',
|
||||
passwordResetToken: hashPublicAccessToken(rawToken),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.employee).not.toHaveProperty('passwordHash')
|
||||
expect(result.employee).not.toHaveProperty('passwordResetToken')
|
||||
expect(result.employee.invitationStatus).toBe('pending')
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'aya@example.com',
|
||||
html: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||
text: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||
html: expect.stringContaining(`http://localhost:3000/dashboard/reset-password?token=${rawToken}`),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('lists team members without password hashes or reset tokens', async () => {
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'emp_1',
|
||||
clerkUserId: 'c1',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
preferredLanguage: 'en',
|
||||
emailVerified: null,
|
||||
passwordHash: '$2a$12$secret',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
] as any)
|
||||
|
||||
const members = await listEmployees('company_1')
|
||||
expect(members[0]).not.toHaveProperty('passwordHash')
|
||||
expect(members[0]).not.toHaveProperty('passwordResetToken')
|
||||
expect(members[0]?.invitationStatus).toBe('accepted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from './notificationService'
|
||||
import { coerceNotificationLocale } from './notificationLocalizationService'
|
||||
import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||
|
||||
@@ -22,12 +23,47 @@ export interface TeamMember {
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
preferredLanguage?: string
|
||||
emailVerified?: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
lastActiveAt?: Date | null
|
||||
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
||||
}
|
||||
|
||||
function presentTeamMember(employee: {
|
||||
id: string
|
||||
clerkUserId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
preferredLanguage?: string
|
||||
emailVerified?: Date | null
|
||||
passwordHash?: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): TeamMember {
|
||||
return {
|
||||
id: employee.id,
|
||||
clerkUserId: employee.clerkUserId,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
email: employee.email,
|
||||
phone: employee.phone,
|
||||
role: employee.role,
|
||||
isActive: employee.isActive,
|
||||
preferredLanguage: employee.preferredLanguage,
|
||||
emailVerified: employee.emailVerified ?? null,
|
||||
createdAt: employee.createdAt,
|
||||
updatedAt: employee.updatedAt,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDashboardBasePath(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
@@ -96,13 +132,24 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
clerkUserId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
preferredLanguage: true,
|
||||
emailVerified: true,
|
||||
passwordHash: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return employees.map((employee: any) => ({
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}))
|
||||
return employees.map(presentTeamMember)
|
||||
}
|
||||
|
||||
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
|
||||
@@ -110,7 +157,8 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' })
|
||||
}
|
||||
|
||||
const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } })
|
||||
const email = payload.email.trim().toLowerCase()
|
||||
const existing = await prisma.employee.findFirst({ where: { companyId, email: { equals: email, mode: 'insensitive' } } })
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
|
||||
}
|
||||
@@ -120,6 +168,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
include: { brand: { select: { defaultLocale: true, displayName: true } } },
|
||||
})
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const tokenHash = hashPublicAccessToken(rawToken)
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
|
||||
|
||||
@@ -129,11 +178,11 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
clerkUserId: `local_member_${crypto.randomUUID()}`,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
email,
|
||||
role: payload.role,
|
||||
isActive: true,
|
||||
preferredLanguage: locale,
|
||||
passwordResetToken: rawToken,
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
@@ -151,18 +200,14 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
)
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: payload.email,
|
||||
to: email,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
})
|
||||
|
||||
return {
|
||||
employee: {
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'pending' as const,
|
||||
},
|
||||
employee: presentTeamMember(employee),
|
||||
invitationId: employee.id,
|
||||
invitedBy: inviterId,
|
||||
}
|
||||
@@ -176,7 +221,8 @@ export async function updateEmployeeRole(companyId: string, requesterId: string,
|
||||
if (payload.role === 'OWNER') throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 })
|
||||
if (target.id === requesterId) throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
@@ -184,14 +230,16 @@ export async function deactivateEmployee(companyId: string, requesterRole: Emplo
|
||||
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
|
||||
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
|
||||
@@ -185,7 +185,7 @@ export const openApiDocument: JsonObject = {
|
||||
'/health': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Health check',
|
||||
summary: 'Liveness health check',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
@@ -193,6 +193,29 @@ export const openApiDocument: JsonObject = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/ready': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Readiness probe (database, redis, storage)',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
'200': { description: 'Ready' },
|
||||
'503': { description: 'Not ready' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/metrics': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Prometheus-style ops metrics',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
'200': { description: 'Prometheus text exposition format' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// AUTH — COMPANY
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import request from 'supertest'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { createApp } from '../../app'
|
||||
import {
|
||||
createCompanyWithEmployee,
|
||||
createVehicle,
|
||||
createCustomer,
|
||||
createReservation,
|
||||
createRentalPayment,
|
||||
signEmployeeToken,
|
||||
authHeader,
|
||||
} from '../helpers/fixtures'
|
||||
|
||||
/**
|
||||
* Phase 3 — cross-tenant negative suite.
|
||||
* Pattern: Company A credentials must not read/mutate Company B resources (404, no leak).
|
||||
*/
|
||||
const app = createApp()
|
||||
|
||||
describe('Cross-tenant isolation (Phase 3)', () => {
|
||||
let companyAId: string
|
||||
let tokenA: string
|
||||
let companyBId: string
|
||||
let employeeBId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const a = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
const b = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
companyAId = a.company.id
|
||||
tokenA = signEmployeeToken(a.employee.id, companyAId, 'OWNER')
|
||||
companyBId = b.company.id
|
||||
employeeBId = b.employee.id
|
||||
})
|
||||
|
||||
it('GET foreign vehicle → 404', async () => {
|
||||
const foreign = await createVehicle(companyBId, { make: 'Foreign' })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/vehicles/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/Foreign/)
|
||||
})
|
||||
|
||||
it('GET foreign vehicle maintenance → 404', async () => {
|
||||
const foreign = await createVehicle(companyBId)
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/vehicles/${foreign.id}/maintenance`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET foreign customer → 404', async () => {
|
||||
const foreign = await createCustomer(companyBId, { firstName: 'SecretTenantB' })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/customers/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/SecretTenantB/)
|
||||
})
|
||||
|
||||
it('GET foreign reservation → 404', async () => {
|
||||
const vehicle = await createVehicle(companyBId)
|
||||
const customer = await createCustomer(companyBId)
|
||||
const foreign = await createReservation(companyBId, vehicle.id, customer.id)
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/reservations/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH foreign customer → not successful', async () => {
|
||||
const foreign = await createCustomer(companyBId)
|
||||
const res = await request(app)
|
||||
.patch(`/api/v1/customers/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
.send({ firstName: 'Hijacked' })
|
||||
expect([404, 403, 405]).toContain(res.status)
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it('GET foreign offer → 404', async () => {
|
||||
const offer = await prisma.offer.create({
|
||||
data: {
|
||||
companyId: companyBId,
|
||||
title: 'SecretOfferB',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 10,
|
||||
validFrom: new Date(),
|
||||
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
} as any,
|
||||
})
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/offers/${offer.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/SecretOfferB/)
|
||||
})
|
||||
|
||||
it('team list does not include foreign employees', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/team')
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(200)
|
||||
const body = JSON.stringify(res.body)
|
||||
expect(body).not.toContain(employeeBId)
|
||||
})
|
||||
|
||||
it('cannot deactivate foreign team member', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/team/${employeeBId}/deactivate`)
|
||||
.set(authHeader(tokenA))
|
||||
expect([404, 403]).toContain(res.status)
|
||||
})
|
||||
|
||||
it('GET payments for foreign reservation → 404 or empty without leak', async () => {
|
||||
const vehicle = await createVehicle(companyBId)
|
||||
const customer = await createCustomer(companyBId, { firstName: 'PayeeSecret' })
|
||||
const reservation = await createReservation(companyBId, vehicle.id, customer.id)
|
||||
await createRentalPayment(companyBId, reservation.id, { amount: 9999 })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/payments/reservations/${reservation.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect([404, 200]).toContain(res.status)
|
||||
if (res.status === 200) {
|
||||
const payments = res.body.data ?? res.body
|
||||
expect(Array.isArray(payments) ? payments.length : 0).toBe(0)
|
||||
}
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/PayeeSecret|9999/)
|
||||
})
|
||||
|
||||
it('reservation JSON never includes reviewToken for own company either (S11)', async () => {
|
||||
const vehicle = await createVehicle(companyAId)
|
||||
const customer = await createCustomer(companyAId)
|
||||
const reservation = await createReservation(companyAId, vehicle.id, customer.id, {
|
||||
reviewToken: 'should-not-leak-phase3',
|
||||
})
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/reservations/${reservation.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(200)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/should-not-leak-phase3/)
|
||||
expect(res.body.data?.reviewToken).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import http from 'node:http'
|
||||
import { assertStorageConfiguration } from '../lib/storage'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { renderPrometheusText, setGauge } from '../lib/opsMetrics'
|
||||
import { startOutboxWorker, startScheduledJobs } from './jobs'
|
||||
|
||||
assertStorageConfiguration()
|
||||
|
||||
const workerId = process.env.WORKER_ID ?? String(process.pid)
|
||||
const metricsPort = Number(process.env.WORKER_METRICS_PORT ?? 0)
|
||||
|
||||
console.log(`[worker] starting jobs worker id=${workerId}`)
|
||||
|
||||
startOutboxWorker()
|
||||
startScheduledJobs()
|
||||
|
||||
if (Number.isFinite(metricsPort) && metricsPort > 0) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ status: 'ok', role: 'worker', workerId }))
|
||||
return
|
||||
}
|
||||
if (req.url === '/metrics') {
|
||||
try {
|
||||
const pending = await prisma.notificationOutbox.count({ where: { status: 'PENDING' } })
|
||||
setGauge('notification_outbox_pending', pending)
|
||||
} catch {
|
||||
/* leave previous gauge */
|
||||
}
|
||||
const body = renderPrometheusText()
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' })
|
||||
res.end(body)
|
||||
return
|
||||
}
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
})
|
||||
server.listen(metricsPort, () => {
|
||||
console.log(`[worker] metrics listening on :${metricsPort}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[worker] ${signal} received, shutting down`)
|
||||
try {
|
||||
await redis.quit()
|
||||
} catch {
|
||||
redis.disconnect()
|
||||
}
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
@@ -0,0 +1,230 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { processNotificationOutbox, sendNotification } from '../services/notificationService'
|
||||
import { observeOutboxProcessed } from '../lib/opsMetrics'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
runPeriodEndCancellationJob,
|
||||
} from '../modules/subscriptions/subscription.service'
|
||||
import { runCollectionsWorker } from '../modules/subscriptions/subscription.collections.service'
|
||||
|
||||
const WORKER_ID = process.env.WORKER_ID ?? `jobs:${process.pid}`
|
||||
const LEADER_KEY = 'rentaldrivego:jobs:leader'
|
||||
const LEADER_TTL_SECONDS = 30
|
||||
|
||||
async function withLeaderLock<T>(fn: () => Promise<T>): Promise<T | null> {
|
||||
const acquired = await redis.set(LEADER_KEY, WORKER_ID, 'EX', LEADER_TTL_SECONDS, 'NX')
|
||||
if (acquired !== 'OK') {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current !== WORKER_ID) return null
|
||||
} else {
|
||||
// refresh TTL periodically while holding
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current === WORKER_ID) await redis.del(LEADER_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
async function renewLeaderIfOwned() {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current === WORKER_ID) await redis.expire(LEADER_KEY, LEADER_TTL_SECONDS)
|
||||
}
|
||||
|
||||
export async function runLicenseExpiryJob() {
|
||||
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
|
||||
for (const c of customers) {
|
||||
if (!c.licenseExpiry) continue
|
||||
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const expired = c.licenseExpiry <= new Date()
|
||||
const expiring = !expired && daysLeft < 90
|
||||
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
|
||||
await prisma.customer.update({
|
||||
where: { id: c.id },
|
||||
data: {
|
||||
licenseExpired: expired,
|
||||
licenseExpiringSoon: expiring,
|
||||
licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTrialEndingRemindersJob() {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (!owner) continue
|
||||
await sendNotification({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
companyId: sub.companyId,
|
||||
employeeId: owner.id,
|
||||
channels: ['IN_APP'],
|
||||
templateKey: 'subscription.trial_ending',
|
||||
templateVariables: {
|
||||
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMaintenanceRemindersJob() {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
vehicle: {
|
||||
include: {
|
||||
company: {
|
||||
include: {
|
||||
employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
const latestByKey = new Map<string, (typeof allCandidates)[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
const reminderDate = now.toISOString().slice(0, 10)
|
||||
await sendNotification({
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channels: ['IN_APP'],
|
||||
sourceType: 'maintenance_log',
|
||||
sourceId: log.id,
|
||||
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Outbox dispatch does its own DB leasing — safe across workers without Redis leader. */
|
||||
export function startOutboxWorker() {
|
||||
cron.schedule('* * * * *', async () => {
|
||||
try {
|
||||
const n = await processNotificationOutbox(50, WORKER_ID)
|
||||
if (n > 0) {
|
||||
observeOutboxProcessed(n)
|
||||
console.log(`[notifications] outbox: ${n} events completed`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[notifications] outbox worker failed:', err?.message ?? err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Scheduled domain jobs — only one leader executes them. */
|
||||
export function startScheduledJobs() {
|
||||
setInterval(() => {
|
||||
void renewLeaderIfOwned()
|
||||
}, 10_000)
|
||||
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
await runLicenseExpiryJob()
|
||||
await runMaintenanceRemindersJob()
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const n = await runTrialExpirationJob()
|
||||
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const nPeriod = await runPeriodEndCancellationJob()
|
||||
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const n = await runCollectionsWorker()
|
||||
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
await runTrialEndingRemindersJob()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3000",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0 -p 3000",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"type-check": "tsc --noEmit",
|
||||
"pretest": "npm run build --workspace @rentaldrivego/types",
|
||||
"test": "vitest run",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:watch": "vitest",
|
||||
@@ -17,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
|
||||
"@rentaldrivego/types": "*",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"next": "^16.2.9",
|
||||
"node-cron": "4.5.0",
|
||||
|
||||
@@ -11,6 +11,16 @@ import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import styles from './AuthForms.module.css';
|
||||
|
||||
function resolveSafeAppRedirect(candidate: string | null | undefined, fallback: string): string {
|
||||
const value = (candidate ?? '').trim();
|
||||
if (!value) return fallback;
|
||||
if (!value.startsWith('/')) return fallback;
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
|
||||
if (value.includes('://')) return fallback;
|
||||
if (/[\r\n\\]/.test(value)) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Dict {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -120,7 +130,7 @@ export function SignInForm({
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
|
||||
const employeeRedirect = resolveSafeAppRedirect(searchParams.get('redirect'), '/dashboard');
|
||||
|
||||
function completeLogin(data: any) {
|
||||
if (data?.admin) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ANNUAL_DISCOUNT_PERCENT,
|
||||
getPublicMonthlyMajorUnits,
|
||||
type PublicPricingPlanId,
|
||||
} from '@rentaldrivego/types';
|
||||
|
||||
export const fleetBandIds = ['small', 'growing', 'scale', 'enterprise'] as const;
|
||||
export type FleetBandId = (typeof fleetBandIds)[number];
|
||||
|
||||
@@ -14,33 +20,28 @@ export interface PricingCommercialConfig {
|
||||
recommendedPlanByFleetBand: Record<FleetBandId, PricingPlanId>;
|
||||
}
|
||||
|
||||
function bandPrices(planId: PublicPricingPlanId): Record<FleetBandId, number | null> {
|
||||
const major = getPublicMonthlyMajorUnits(planId);
|
||||
return {
|
||||
small: major,
|
||||
growing: major,
|
||||
scale: major,
|
||||
enterprise: major,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the only file that should contain commercial pricing numbers.
|
||||
* Public prices apply only to non-custom plans. Enterprise remains custom.
|
||||
* Public marketing prices derived from `@rentaldrivego/types` planCatalog.
|
||||
* Do not hard-code MAD amounts here — change PLAN_PRICES in packages/types.
|
||||
*/
|
||||
export const pricingCommercialConfig: PricingCommercialConfig = {
|
||||
currency: 'MAD',
|
||||
annualDiscountPercent: 20,
|
||||
annualDiscountPercent: ANNUAL_DISCOUNT_PERCENT,
|
||||
publicPricesApproved: true,
|
||||
monthlyPrices: {
|
||||
launch: {
|
||||
small: 149,
|
||||
growing: 149,
|
||||
scale: 149,
|
||||
enterprise: 149,
|
||||
},
|
||||
growth: {
|
||||
small: 299,
|
||||
growing: 299,
|
||||
scale: 299,
|
||||
enterprise: 299,
|
||||
},
|
||||
enterprise: {
|
||||
small: null,
|
||||
growing: null,
|
||||
scale: null,
|
||||
enterprise: null,
|
||||
},
|
||||
launch: bandPrices('launch'),
|
||||
growth: bandPrices('growth'),
|
||||
enterprise: bandPrices('enterprise'),
|
||||
},
|
||||
recommendedPlanByFleetBand: {
|
||||
small: 'launch',
|
||||
|
||||
@@ -120,8 +120,48 @@ services:
|
||||
- app_node_modules:/app/node_modules
|
||||
- api_uploads_dev:/var/lib/rentaldrivego/storage
|
||||
restart: unless-stopped
|
||||
profiles: ["api", "homepage", "carplace", "dashboard", "admin", "full"]
|
||||
|
||||
api-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
types:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- .env.docker.dev
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
|
||||
WORKER_ID: api-worker-1
|
||||
command: ["sh", "-c", "npm run build --workspace @rentaldrivego/types && cd /app/apps/api && exec /app/node_modules/.bin/ts-node-dev --respawn --transpile-only src/workers/index.ts"]
|
||||
volumes:
|
||||
- .:/app
|
||||
- app_node_modules:/app/node_modules
|
||||
- api_uploads_dev:/var/lib/rentaldrivego/storage
|
||||
restart: unless-stopped
|
||||
profiles: ["api", "full"]
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_dev_data:/data
|
||||
profiles: ["storage", "full"]
|
||||
|
||||
homepage:
|
||||
build:
|
||||
context: .
|
||||
@@ -244,6 +284,7 @@ volumes:
|
||||
app_node_modules:
|
||||
postgres_bootstrap_state:
|
||||
api_uploads_dev:
|
||||
minio_dev_data:
|
||||
homepage_next:
|
||||
carplace_next:
|
||||
dashboard_next:
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -0,0 +1,20 @@
|
||||
# ADR-001: Disable per-tenant Docker container orchestration
|
||||
|
||||
**Status:** Accepted (Phase 0 production readiness)
|
||||
**Date:** 2026-08-12
|
||||
|
||||
## Decision
|
||||
|
||||
Per-tenant Docker Compose / Docker-socket management is **out of production scope**. The previous `containerService` and admin Containers UI must not run in GA.
|
||||
|
||||
## Context
|
||||
|
||||
- The admin UI called `/admin/containers*` while matching API routes / `CompanyContainer` model were incomplete.
|
||||
- `containerService` built Compose YAML via string interpolation and expected Docker socket access — a near-host RCE trust boundary inside the business data plane.
|
||||
- Production readiness requires removing privileged incomplete features before scaling.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `apps/api/src/services/containerService.ts` fails closed (`501 container_feature_disabled`).
|
||||
- Admin containers page shows an explicit out-of-scope notice.
|
||||
- Any future isolation requirement needs a separate controller with signed templates, quotas, audit, and no tenant-facing Docker socket.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-002 — Defer Postgres RLS until app-level isolation is proven
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (Phase 3) — **RLS not enabled yet**
|
||||
|
||||
## Context
|
||||
|
||||
Phase 3 lists optional Postgres row-level security after app-level cross-tenant tests. The API already scopes queries with `companyId` from the authenticated session. Enabling RLS without a complete policy matrix and migration path risks breaking admin, workers, migrations, and reporting jobs that use elevated DB roles.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Land and keep expanding the app-level suite (`apps/api/src/tests/integration/cross-tenant-isolation.test.ts`).
|
||||
2. Do **not** enable `FORCE ROW LEVEL SECURITY` in production until:
|
||||
- Cross-tenant suite covers vehicles, customers, reservations, payments, team, billing reads
|
||||
- Worker and migration DB roles are designed (`BYPASSRLS` or dedicated policies)
|
||||
- A staging soak proves no latent `findMany` without tenant predicates
|
||||
3. Revisit RLS as a defense-in-depth layer in a dedicated change set — not as a gate to start Phase 3 assurance work.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tenant safety remains an application responsibility in the near term.
|
||||
- Pen-testers should still treat missing `companyId` filters as Critical.
|
||||
- Future RLS work tracks under Phase 3 optional / post-GA hardening.
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR-003 — Do not extract microservices until measurement gates pass
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (Phase 4 planning) — **modular monolith remains the default**
|
||||
|
||||
## Context
|
||||
|
||||
The production-readiness plan and diligence review both forbid a microservices rewrite for its own sake. Phase 1 already isolated the **notification/job worker process** inside the same deployable (`api-worker`). Phase 4 candidates (payments/webhooks, notification worker as a separate *service*, media processing) are optional extractions only after Phase 3 load/ownership evidence shows a real bottleneck or blast-radius problem.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Default:** keep one API codebase + dedicated worker process(es) sharing the same package and schema.
|
||||
2. **Do not** create new deployable services for payments, webhooks, or media until **all** gates in `docs/ops/phase4-extraction-gates.md` are met for that candidate.
|
||||
3. Prefer in-monolith hardening first: clearer module boundaries, queue isolation, separate worker replicas, object-storage media pipeline — without new network hops.
|
||||
4. Any approved extraction must ship with: ownership, SLO, independent deploy/rollback, contract tests, and dual-run evidence before cutting over.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Phase 4 work in-repo is **gates, measurement templates, and module-boundary notes** — not a service split.
|
||||
- Claiming “we moved to microservices” without gate evidence is a non-goal (§11 of the readiness plan).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Billing & notification source-of-truth map
|
||||
|
||||
**Status:** Phase 2 convergence guide
|
||||
**Date:** 2026-08-12
|
||||
|
||||
## Billing
|
||||
|
||||
| Generation | Models | Rule |
|
||||
|------------|--------|------|
|
||||
| **Canonical** | `BillingAccount`, `BillingInvoice`, line items, intents, attempts, credits, tax, refunds | Write path for new money movement |
|
||||
| **Legacy compat** | `SubscriptionInvoice`, older payment-attempt shapes linked via `billingInvoiceId` | Read/migrate only; do not create new orphans |
|
||||
|
||||
**Invariant:** One money-moving command → one canonical `BillingInvoice` (or intent) → provider idempotency key → webhook reconciliation → auditable outcome.
|
||||
|
||||
Until legacy rows are archived:
|
||||
|
||||
1. Prefer canonical reads in admin/finance UI
|
||||
2. Keep dual-read adapters only where needed for historical invoices
|
||||
3. Block new writes that create legacy-only invoice rows
|
||||
|
||||
## Notifications
|
||||
|
||||
| Generation | Models | Rule |
|
||||
|------------|--------|------|
|
||||
| **Canonical** | `NotificationEvent` → `NotificationRecipient` → `NotificationDelivery` + `NotificationOutbox` | All new product notifications |
|
||||
| **Legacy** | Flat `Notification` | Migrate reads; no new writes after GA |
|
||||
|
||||
**Dispatch:** Outbox worker (Phase 1) is the only delivery executor. Direct `sendTransactionalEmail` remains allowed for auth/security messages that must not wait on the outbox.
|
||||
|
||||
## Exit evidence for convergence
|
||||
|
||||
- [ ] Inventory counts of legacy vs canonical rows in staging/prod
|
||||
- [ ] No new legacy-only writes in code paths covered by tests
|
||||
- [ ] Retention policy applied consistently across both generations during migration
|
||||
@@ -0,0 +1,37 @@
|
||||
# Privacy data map (Phase 2 baseline)
|
||||
|
||||
**Status:** Baseline inventory for production-readiness — not a legal opinion or DPIA.
|
||||
**Owner:** Engineering + ops (assign legal owner before GA)
|
||||
**Date:** 2026-08-12
|
||||
|
||||
## Data classes
|
||||
|
||||
| Class | Examples | Storage | Access | Retention target (TBD / approve) |
|
||||
|-------|----------|---------|--------|-----------------------------------|
|
||||
| Account identity | Employee/admin/renter email, name, phone | PostgreSQL | Tenant roles / admin | Account life + 30 days |
|
||||
| Auth secrets | Password hashes, TOTP secrets, hashed reset tokens | PostgreSQL | Auth services only | Until rotated/cleared |
|
||||
| Customer PII | Customer name, DOB, nationality, address | PostgreSQL | Tenant employees | Contract life + local legal minimum |
|
||||
| License evidence | License images, numbers, expiry | Private storage + DB refs | Authenticated customer routes | Contract life + dispute window |
|
||||
| Rental evidence | Reservation photos, contracts/PDFs, damage inspections | Private/public storage + DB | Tenant + limited public tokens | Contract life + dispute window |
|
||||
| Billing | Invoices, payment intents, manual payment evidence | PostgreSQL + private storage | Finance roles + fresh admin 2FA where required | 7–10 years (finance — confirm) |
|
||||
| Notifications | Notification events, deliveries, preferences | PostgreSQL | Actor inbox APIs | 90–180 days operational |
|
||||
| Audit | Admin `AuditLog` rows | PostgreSQL | Admin roles | 1–2 years minimum |
|
||||
|
||||
## Controls in code today
|
||||
|
||||
- Private storage split + blocked anonymous customer/reservation storage paths
|
||||
- HttpOnly cookies; admin 2FA; hashed API keys / invite tokens (Phase 0)
|
||||
- Admin audit log for privileged platform actions
|
||||
- Ops metrics do **not** include PII payloads
|
||||
|
||||
## Gaps to close before claiming privacy compliance
|
||||
|
||||
- [ ] Field-level encryption for highest-risk PII (license numbers, government IDs)
|
||||
- [ ] Automated retention/deletion jobs with legal hold exceptions
|
||||
- [ ] DSAR export/delete runbooks with evidence
|
||||
- [ ] Privileged-read logging for license images and payment evidence downloads
|
||||
- [ ] Processor inventory + DPA list
|
||||
|
||||
## Privileged-read audit expectation
|
||||
|
||||
Every successful read of license images, contract PDFs, damage photos, or payment evidence by support/admin impersonation must write an `AuditLog` (or equivalent immutable record) with actor, subject, resource id, and request id.
|
||||
+6
-12
@@ -66,25 +66,19 @@ Layer 4: api.RentalDriveGo.com — REST API
|
||||
rental-car-site/
|
||||
├── README.md
|
||||
├── docs/
|
||||
│ ├── design/ ← Production-readiness design updates (Phases 0–4)
|
||||
│ ├── project-design/ ← Feature / API / schema design
|
||||
│ ├── ops/ ← Runbooks, readiness plan, drills
|
||||
│ ├── ARCHITECTURE.md ← Payment providers, carplace redirect model, photo flow
|
||||
│ ├── DESIGN_SYSTEM.md
|
||||
│ ├── PAGES.md ← All pages. Carplace = discovery only. Booking = company site.
|
||||
│ ├── FEATURES.md
|
||||
│ └── DEPLOYMENT.md
|
||||
└── skills/
|
||||
├── rental-car-website/
|
||||
├── rental-car-components/
|
||||
├── rental-car-backend/
|
||||
│ └── references/
|
||||
│ ├── schema.md ← AmanPay/PayPal fields, no Stripe
|
||||
│ ├── api-routes.md ← All routes
|
||||
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
|
||||
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
|
||||
│ ├── subdomain-service.md ← Carplace redirect + company site
|
||||
│ └── notification-service.md ← All 5 channels
|
||||
└── rental-car-i18n/
|
||||
└── apps / packages / scripts ← See monorepo root README
|
||||
```
|
||||
|
||||
See **`docs/design/README.md`** for what changed during the production-readiness program.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Commercial and data design updates (Phase 2)
|
||||
|
||||
## Plan / entitlement catalog
|
||||
|
||||
**Single source of truth:** `packages/types/src/planCatalog.ts`
|
||||
|
||||
Exports (via `@rentaldrivego/types` / `api.ts` re-exports):
|
||||
|
||||
- `PLAN_PRICES`, `PLAN_ENTITLEMENTS`, capabilities, public marketing map (`launch` → `STARTER`, etc.)
|
||||
- Helpers: `getVehicleLimit`, `getPublicMonthlyMajorUnits`, `planHasCapability`
|
||||
|
||||
**Consumers:**
|
||||
|
||||
- Homepage marketing prices → `apps/homepage/src/content/pricing-config.ts`
|
||||
- API site/subscription fallbacks → `@rentaldrivego/types`
|
||||
- Vehicle fleet limits → catalog first (no duplicate hardcoded fallback table)
|
||||
|
||||
Contract tests: `packages/types/src/planCatalog.test.ts` (`npm run test:types`).
|
||||
|
||||
## Billing & notifications source of truth
|
||||
|
||||
See `docs/BILLING_NOTIFICATION_SOURCE_OF_TRUTH.md`:
|
||||
|
||||
| Domain | Canonical | Legacy |
|
||||
|--------|-----------|--------|
|
||||
| Money | `BillingAccount` / `BillingInvoice` / intents | `SubscriptionInvoice` (compat reads only) |
|
||||
| Notifications | `NotificationEvent` → recipients → deliveries + **outbox** | Flat `Notification` |
|
||||
|
||||
Dispatch design: outbox worker is the delivery executor; direct transactional email remains allowed for auth/security messages.
|
||||
|
||||
## Privacy baseline
|
||||
|
||||
`docs/PRIVACY_DATA_MAP.md` inventories data classes, storage, access, and open gaps (field encryption, DSAR automation, privileged-read audit). Ops metrics intentionally exclude PII payloads.
|
||||
|
||||
## OpenAPI completeness
|
||||
|
||||
Design rule: ops endpoints `/health`, `/ready`, `/metrics` must appear in OpenAPI. CI runs `npm run openapi:coverage` (`scripts/check-openapi-coverage.mjs`).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Production readiness — what was implemented
|
||||
|
||||
**Date:** 2026-08-12
|
||||
**Code root:** monorepo `apps/*`, `packages/*`
|
||||
**Plan:** `docs/ops/RentalDriveGo_Production_Readiness_Plan.md`
|
||||
|
||||
This document is the design-facing summary of work already landed. It is not a backlog.
|
||||
|
||||
---
|
||||
|
||||
## Architecture decision (unchanged)
|
||||
|
||||
Keep a **modular Express monolith** plus a dedicated **job worker process**. Do not split payments/webhooks/media into microservices until measurement gates pass (ADR-003).
|
||||
|
||||
```
|
||||
[Traefik] → [API × N] → PostgreSQL
|
||||
↓ ↑
|
||||
[Redis] [api-worker]
|
||||
↓
|
||||
[local disk | S3/MinIO]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Security & envelope
|
||||
|
||||
| Area | Design outcome | Primary paths |
|
||||
|------|----------------|---------------|
|
||||
| Team API secrets | Safe presenters; no `passwordHash` / raw reset tokens in responses | `apps/api/src/services/teamService.ts` |
|
||||
| Invite tokens | Stored as SHA-256 hashes | `teamService.ts` |
|
||||
| Admin presenters | Scrub reset/verification secrets | `admin.presenter.ts` |
|
||||
| Per-tenant Docker | Feature **fail-closed**; UI out-of-scope | `containerService.ts`, ADR-001 |
|
||||
| Login redirects | Relative same-origin allowlist only | dashboard `SignInForm` |
|
||||
| Payment return URLs | Authenticated checkout allowlists | `paymentRedirects.ts` |
|
||||
| Admin slugs | Validated / slugified | admin schemas/repo |
|
||||
| Employee email | `@@unique([companyId, email])`; ambiguous login fails closed | Prisma + auth repo |
|
||||
| Reset / email verify tokens | Hash-only lookup (no raw dual-match) | employee/admin repos |
|
||||
| Env templates | Scrubbed of real-looking secrets | `.env.example`, docker env samples |
|
||||
| CI hygiene | `security:static` + production `npm audit` gate | `.gitea/workflows/test.yml` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Replica-safe shared state
|
||||
|
||||
| Area | Design outcome | Primary paths |
|
||||
|------|----------------|---------------|
|
||||
| Job plane | Dedicated `api-worker`; API embeds jobs only if `ENABLE_EMBEDDED_JOBS=true` | `apps/api/src/workers/` |
|
||||
| Notification outbox | DB leases (`lockedAt` / `lockedBy` / `attempts` / `availableAt`) | `notificationService` + migration |
|
||||
| Realtime | Redis publish on IN_APP delivery | worker / notification service |
|
||||
| Rate limits | Shared Redis store | `redisRateLimitStore.ts` |
|
||||
| Carplace idempotency | Redis-backed store | `idempotencyStore.ts` |
|
||||
| Files | `FILE_STORAGE_DRIVER=local\|s3` (+ MinIO compose profile) | `lib/storage`, `objectStorage` |
|
||||
| Readiness | `GET /ready` checks DB, Redis, storage | `app.ts` |
|
||||
| Shutdown | SIGTERM drains API and worker | `index.ts`, `workers/index.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Operational control (code baselines)
|
||||
|
||||
| Area | Design outcome | Primary paths |
|
||||
|------|----------------|---------------|
|
||||
| Metrics / logs | Structured JSON access logs; Prometheus text at `GET /metrics` | `opsMetrics.ts`, `app.ts` |
|
||||
| Worker metrics | Optional `WORKER_METRICS_PORT` exposes `/metrics` + `/health` | `workers/index.ts` |
|
||||
| Plan catalog | Single source in `@rentaldrivego/types` | `packages/types/src/planCatalog.ts` |
|
||||
| Homepage pricing | Imports catalog (no hard-coded MAD amounts) | `apps/homepage/.../pricing-config.ts` |
|
||||
| OpenAPI gate | Coverage script + CI step | `scripts/check-openapi-coverage.mjs` |
|
||||
| Backup smoke | Artifact checker + RPO/RTO checklist | `scripts/backup-restore-*` |
|
||||
| Privacy / SoT docs | Baseline maps | `docs/PRIVACY_DATA_MAP.md`, `docs/BILLING_NOTIFICATION_SOURCE_OF_TRUTH.md` |
|
||||
|
||||
**Still evidence-only:** staging alert fire, dated restore drill, provider reconciliation sample, privacy owners.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Scale & assurance (code / runbooks)
|
||||
|
||||
| Area | Design outcome | Primary paths |
|
||||
|------|----------------|---------------|
|
||||
| Cross-tenant suite | Vehicles, customers, reservations, offers, team, payments negatives | `cross-tenant-isolation.test.ts` |
|
||||
| S11 | `reviewToken` omitted from API JSON | reservation presenter, review service |
|
||||
| S12 | Public booking token: GET does not burn; payment **consumes** once | `site.service.ts` / `site.repo.ts` |
|
||||
| S13 | Fresh admin 2FA max-age (default 30 minutes) | `requireFreshAdmin2FA` |
|
||||
| Soak / chaos | Node soak probe + optional k6; failure-injection helper | `scripts/load/`, `scripts/chaos/` |
|
||||
| Canary / keys / pen-test | Runbooks and scope pack | `docs/ops/`, `docs/security/pen-test-scope.md` |
|
||||
| Postgres RLS | Explicitly deferred | ADR-002 |
|
||||
|
||||
**Still evidence-only:** soak/chaos drill records, pen-test report, canary + key-rotation drills.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Extract only if measured
|
||||
|
||||
| Area | Design outcome |
|
||||
|------|----------------|
|
||||
| Default | Stay on monolith + `api-worker` |
|
||||
| Gates | G1–G7 in `docs/ops/phase4-extraction-gates.md` |
|
||||
| Candidates | Notifications worker deployable, payments/webhooks, media — **blocked** until measurement |
|
||||
| Code change | None required for “phase complete”; ADR-003 + templates only |
|
||||
|
||||
---
|
||||
|
||||
## Explicit non-goals (still)
|
||||
|
||||
- Claiming production ready without §13 evidence
|
||||
- Shipping per-tenant container orchestration
|
||||
- Microservices rewrite without gate evidence
|
||||
- Marketing KYC / OCR authenticity beyond license date checks
|
||||
@@ -0,0 +1,32 @@
|
||||
# Design docs — production-readiness updates
|
||||
|
||||
This folder records **architecture and design changes** landed while taking RentalDriveGo to production readiness (Phases 0–4). It complements the longer plans under `docs/ops/` and `docs/project-design/`.
|
||||
|
||||
## Investor diligence (Word)
|
||||
|
||||
| Document | Notes |
|
||||
|----------|--------|
|
||||
| [RentalDriveGo_Technical_Architecture_and_Investor_Due_Diligence.docx](./RentalDriveGo_Technical_Architecture_and_Investor_Due_Diligence.docx) | **v1.1 (12 Aug 2026)** — updated executive status, risk/roadmap annotations, and **Addendum A** for Phases 0–4 implementation. Original v1.0 backup: `RentalDriveGo_Technical_Architecture_and_Investor_Due_Diligence.v1.0.backup.docx` |
|
||||
|
||||
## Markdown design notes
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [PRODUCTION_READINESS_IMPLEMENTED.md](./PRODUCTION_READINESS_IMPLEMENTED.md) | What was implemented per phase (code + runbooks) |
|
||||
| [RUNTIME_AND_OPS_SURFACE.md](./RUNTIME_AND_OPS_SURFACE.md) | Worker, Redis, storage, `/ready`, `/metrics`, env knobs |
|
||||
| [SECURITY_DESIGN_UPDATES.md](./SECURITY_DESIGN_UPDATES.md) | Security findings S1–S15 design outcomes |
|
||||
| [COMMERCIAL_AND_DATA_DESIGN.md](./COMMERCIAL_AND_DATA_DESIGN.md) | Plan catalog, billing/notification SoT, privacy map |
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- `docs/ADR-001-disable-per-tenant-containers.md`
|
||||
- `docs/ADR-002-defer-postgres-rls.md`
|
||||
- `docs/ADR-003-defer-service-extraction.md`
|
||||
|
||||
## Canonical execution plan
|
||||
|
||||
- `docs/ops/RentalDriveGo_Production_Readiness_Plan.md`
|
||||
|
||||
## Status (2026-08-12)
|
||||
|
||||
**Application code and in-repo runbooks for Phases 0–4 are complete.** Remaining production-ready gates are **ops evidence** (CI smoke, restore/alert drills, soak, pen-test), not missing product features. Phase 4 does **not** extract microservices by default (ADR-003).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Runtime and ops surface (post Phases 1–2)
|
||||
|
||||
Design note for operators and engineers. Reflects the **current** intended production topology.
|
||||
|
||||
## Processes
|
||||
|
||||
| Process | Role | Notes |
|
||||
|---------|------|--------|
|
||||
| `api` | HTTP API | Do **not** set `ENABLE_EMBEDDED_JOBS=true` when running multiple API replicas |
|
||||
| `api-worker` | Outbox + scheduled jobs | Uses DB leases + Redis leader lock for cron |
|
||||
| Frontends | homepage, carplace, dashboard, admin | Unchanged modular apps |
|
||||
| postgres | System of record | |
|
||||
| redis | Rate limit, idempotency, pub/sub, locks | Required for replica-safe behavior |
|
||||
| object storage | Optional S3/MinIO | Prefer over local disk for multi-replica files |
|
||||
|
||||
## HTTP ops endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/health` | Liveness |
|
||||
| GET | `/ready` | Readiness: database, redis, storage |
|
||||
| GET | `/metrics` | Prometheus-style counters/gauges (latency, status, outbox pending/published) |
|
||||
| GET | `/api/v1/openapi.json` | OpenAPI document |
|
||||
| GET | `/docs` | Swagger UI (when enabled) |
|
||||
|
||||
Worker (optional):
|
||||
|
||||
| Env | Behavior |
|
||||
|-----|----------|
|
||||
| `WORKER_METRICS_PORT=<port>` | Worker listens for `GET /metrics` and `GET /health` |
|
||||
| `0` / unset | No worker HTTP listener |
|
||||
|
||||
## Important environment knobs
|
||||
|
||||
Documented in `.env.example`:
|
||||
|
||||
- `ENABLE_EMBEDDED_JOBS` — default false in production examples
|
||||
- `RATE_LIMIT_STORE` / `IDEMPOTENCY_STORE` — prefer `redis`
|
||||
- `FILE_STORAGE_DRIVER` — `local` or `s3` (+ `S3_*`)
|
||||
- `TRUSTED_FORWARD_HEADERS` — default false; see `docs/ops/proxy-trust.md`
|
||||
- `ADMIN_FRESH_2FA_MAX_AGE_MS` — default `1800000` (30 minutes)
|
||||
- `WORKER_METRICS_PORT` — optional
|
||||
|
||||
## Observability design
|
||||
|
||||
- Access logs: structured JSON via morgan in `app.ts`
|
||||
- In-process series: `apps/api/src/lib/opsMetrics.ts`
|
||||
- Durable outbox gauges: API `/metrics` also counts PENDING / PUBLISHED rows from PostgreSQL so scrapes remain useful when jobs run only on the worker
|
||||
|
||||
## Failure / release runbooks
|
||||
|
||||
- Chaos helper: `scripts/chaos/failure-injection.sh`
|
||||
- Soak: `npm run test:soak` → `scripts/load/soak-probe.mjs`
|
||||
- Canary / rollback: `docs/ops/canary-rollback.md`
|
||||
- Key rotation: `docs/ops/key-rotation-drill.md`
|
||||
- Backup smoke: `scripts/backup-restore-smoke-check.sh`
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
# Security design updates (Phases 0 & 3)
|
||||
|
||||
Summary of application-security design changes from the production-readiness program. Full finding table lives in `docs/ops/RentalDriveGo_Production_Readiness_Plan.md` §3.
|
||||
|
||||
## Closed in code (S1–S15)
|
||||
|
||||
| ID | Design rule now enforced |
|
||||
|----|---------------------------|
|
||||
| S1 | Team list/invite responses use safe presenters — never return password hashes or raw reset tokens |
|
||||
| S2 | Per-tenant Docker/Compose orchestration is **out of production scope** (fail-closed + ADR-001) |
|
||||
| S3 | Invite tokens hashed at rest (SHA-256) |
|
||||
| S4 | Employee email unique per company; login fails closed on ambiguity |
|
||||
| S5 | Post-login redirects limited to safe relative paths |
|
||||
| S6 | Authenticated payment/subscription return URLs allowlisted |
|
||||
| S7 | Admin company slugs validated / slugified |
|
||||
| S8 | Admin presenters strip reset/verification secrets |
|
||||
| S9 | Forwarded headers scrubbed by default; `TRUSTED_FORWARD_HEADERS=true` only behind a scrubbing edge (`docs/ops/proxy-trust.md`) |
|
||||
| S10 | Password reset token lookup is hash-only |
|
||||
| S11 | `reviewToken` is never returned in reservation/review API JSON (still used server-side for email links) |
|
||||
| S12 | Public booking access: **read** does not burn the token; **payment init** atomically consumes an unused token |
|
||||
| S13 | Admin money / privileged mutations require 2FA proof newer than `ADMIN_FRESH_2FA_MAX_AGE_MS` |
|
||||
| S14 | `.gitignore` present for secrets/build artifacts |
|
||||
| S15 | `npm run security:static` in CI |
|
||||
|
||||
## Public booking token (S12) flow
|
||||
|
||||
```
|
||||
createBooking → mint publicAccessToken (hash stored)
|
||||
│
|
||||
├─ GET booking?token=… → validate (used or unused OK until expiry) — do not consume
|
||||
│
|
||||
└─ initPayment(token) → require usedAt IS NULL → updateMany set usedAt → proceed
|
||||
(second payment attempt with same token → 404)
|
||||
```
|
||||
|
||||
## Fresh admin 2FA (S13)
|
||||
|
||||
`requireFreshAdmin2FA` rejects when:
|
||||
|
||||
- TOTP not enrolled, or
|
||||
- JWT lacks `last2faAt`, or
|
||||
- `now - last2faAt > ADMIN_FRESH_2FA_MAX_AGE_MS` (default 30 minutes)
|
||||
|
||||
Wired on finance/support money and high-privilege admin mutations.
|
||||
|
||||
## Tenant isolation design
|
||||
|
||||
- App-level: every company resource query includes `companyId` from the authenticated session
|
||||
- Regression suite: `apps/api/src/tests/integration/cross-tenant-isolation.test.ts`
|
||||
- Postgres RLS: **deferred** (ADR-002) until the app-level suite and worker/migration roles are ready
|
||||
|
||||
## Pen-test
|
||||
|
||||
Scope pack: `docs/security/pen-test-scope.md`. Reports belong under `security-reports/` (evidence, not design).
|
||||
@@ -0,0 +1,357 @@
|
||||
from docx import Document
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
src = Path(r"D:\1\management\docs\design\RentalDriveGo_Technical_Architecture_and_Investor_Due_Diligence.docx")
|
||||
backup = src.with_suffix(".v1.0.backup.docx")
|
||||
if not backup.exists():
|
||||
shutil.copy2(src, backup)
|
||||
print("backup:", backup)
|
||||
|
||||
doc = Document(str(src))
|
||||
|
||||
|
||||
def set_runs_text(paragraph, text: str) -> None:
|
||||
if paragraph.runs:
|
||||
paragraph.runs[0].text = text
|
||||
for r in paragraph.runs[1:]:
|
||||
r.text = ""
|
||||
else:
|
||||
paragraph.add_run(text)
|
||||
|
||||
|
||||
# --- Title / version updates ---
|
||||
for i, p in enumerate(doc.paragraphs[:15]):
|
||||
t = p.text.strip()
|
||||
if t.startswith("Version 1.0"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Version 1.1 • 12 August 2026 (addendum update of v1.0 dated 9 August 2026)",
|
||||
)
|
||||
print(f"updated version para {i}")
|
||||
if "Prepared from the supplied" in t:
|
||||
set_runs_text(
|
||||
p,
|
||||
"Prepared from the car_management / management monorepo; v1.1 reflects Phases 0–4 "
|
||||
"production-readiness implementation status as of 12 August 2026",
|
||||
)
|
||||
print(f"updated prepared para {i}")
|
||||
|
||||
# --- Soft-update executive bottom line ---
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
if p.text.startswith("Bottom line"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Bottom line (updated 12 Aug 2026) RentalDriveGo remains a serious multi-application "
|
||||
"rental SaaS with strong domain depth. Since the 9 August 2026 archive review, the monorepo "
|
||||
"has been restored and Phases 0–4 of the production-readiness program have landed in "
|
||||
"application code and in-repo runbooks: security P0/P1 fixes, dedicated api-worker with "
|
||||
"leased outbox, Redis rate-limit/idempotency, object-storage driver, /ready and /metrics, "
|
||||
"single plan catalog, OpenAPI coverage gate, cross-tenant tests, and extraction deferred "
|
||||
"pending measurement (ADR-003). The platform is still not “production ready” as an investor "
|
||||
"claim until clean-runner CI/SCA evidence, Compose multi-replica smoke, restore/alert drills, "
|
||||
"soak/pen-test, and §13 checkpoint items are recorded. The accurate frame is now: product + "
|
||||
"operational foundations exist in code; remaining capital/ops work is evidence and assurance, "
|
||||
"not a greenfield rebuild.",
|
||||
)
|
||||
print(f"updated bottom line para {i}")
|
||||
break
|
||||
|
||||
# Update investment interpretation cells
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
cells = [c.text.strip() for c in row.cells]
|
||||
if not cells:
|
||||
continue
|
||||
dim = cells[0]
|
||||
if dim == "Delivery reproducibility" and len(cells) >= 3:
|
||||
row.cells[1].text = "Mostly restored in tree"
|
||||
row.cells[2].text = (
|
||||
"turbo.json, tsconfig.base.json, scripts, Compose, backup/restore present; "
|
||||
"clean-runner CI green still needs recorded evidence."
|
||||
)
|
||||
elif dim == "Scale readiness" and len(cells) >= 3:
|
||||
row.cells[1].text = "Improved (code)"
|
||||
row.cells[2].text = (
|
||||
"api-worker, Redis stores, S3/MinIO driver, readiness/shutdown landed; "
|
||||
"two-replica soak evidence still open."
|
||||
)
|
||||
elif dim == "Operational maturity" and len(cells) >= 3:
|
||||
row.cells[1].text = "Baseline in code"
|
||||
row.cells[2].text = (
|
||||
"/metrics, structured logs, backup smoke, runbooks present; "
|
||||
"alert/restore/pen-test drills still open."
|
||||
)
|
||||
elif dim == "Security design" and len(cells) >= 3:
|
||||
row.cells[1].text = "Strengthened"
|
||||
row.cells[2].text = (
|
||||
"S1–S15 application findings closed or documented; containers fail-closed; "
|
||||
"fresh 2FA TTL; review tokens scrubbed; pen-test still required."
|
||||
)
|
||||
|
||||
# Update material findings glance
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
texts = [c.text for c in row.cells]
|
||||
joined = " | ".join(texts)
|
||||
if "turbo.json and tsconfig.base.json are absent" in joined and len(row.cells) >= 3:
|
||||
row.cells[0].text = "P0→Mitigated"
|
||||
row.cells[1].text = (
|
||||
"Monorepo envelope (turbo.json, tsconfig.base.json, scripts, Compose) "
|
||||
"restored in the management tree."
|
||||
)
|
||||
row.cells[2].text = "Remaining: recorded clean-runner CI/smoke evidence."
|
||||
elif "21 vulnerabilities" in joined and len(row.cells) >= 3:
|
||||
row.cells[0].text = "P0→Open evidence"
|
||||
row.cells[1].text = (
|
||||
"CI now runs production npm audit (fail on high); "
|
||||
"local/registry results still time-sensitive."
|
||||
)
|
||||
row.cells[2].text = "Clear critical/high on a clean runner or file dated exceptions."
|
||||
elif ("Per-tenant container" in joined or "container orchestration" in joined.lower()) and len(
|
||||
row.cells
|
||||
) >= 3:
|
||||
row.cells[0].text = "P0→Mitigated"
|
||||
row.cells[1].text = "Container feature fail-closed; admin UI out-of-scope; ADR-001 accepted."
|
||||
row.cells[2].text = "Do not re-enable Docker-socket tenant isolation."
|
||||
elif "no dispatcher/worker was found" in joined and len(row.cells) >= 3:
|
||||
row.cells[0].text = "P1→Mitigated in code"
|
||||
row.cells[1].text = "Dedicated api-worker with leased outbox dispatch and Redis realtime publish."
|
||||
row.cells[2].text = "Prove under two API replicas + worker in staging."
|
||||
elif "Rate limits" in joined and "process-local" in joined.lower() and len(row.cells) >= 3:
|
||||
row.cells[0].text = "P1→Mitigated in code"
|
||||
row.cells[1].text = (
|
||||
"Redis rate-limit store, Redis Carplace idempotency, cron leader lock, S3/local storage driver."
|
||||
)
|
||||
row.cells[2].text = "Multi-replica correctness evidence still required."
|
||||
elif (
|
||||
"No complete CI/CD, observability" in joined
|
||||
or "graceful shutdown, backup/restore" in joined
|
||||
) and len(row.cells) >= 3:
|
||||
row.cells[0].text = "P1→Partial"
|
||||
row.cells[1].text = (
|
||||
"CI security/OpenAPI gates, /ready, /metrics, backup smoke scripts and ops runbooks landed."
|
||||
)
|
||||
row.cells[2].text = "Alert/restore/pen-test/soak evidence still open."
|
||||
|
||||
# Update recommendation bullets
|
||||
for p in doc.paragraphs:
|
||||
if p.text.startswith("Treat the current asset as a capable beta"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Treat the current asset as a late beta / hardening-stage platform: application foundations "
|
||||
"for production readiness are largely implemented; do not claim production-hardened SaaS until "
|
||||
"exit evidence (§13) is complete.",
|
||||
)
|
||||
if p.text.startswith("Condition any production or scale claim"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Condition any production or scale claim on remaining evidence gates: clean CI/SCA, "
|
||||
"two-replica Compose smoke, restore meeting RPO/RTO, alert/failure drill, soak, "
|
||||
"independent pen-test, and provider reconciliation sample.",
|
||||
)
|
||||
if p.text.startswith("Fund a 90-day production-readiness program"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Continue the funded production-readiness program through evidence close-out; Phase 4 "
|
||||
"service extraction remains deferred until measurement gates pass (ADR-003). Reevaluate "
|
||||
"extraction only from measured load and ownership boundaries.",
|
||||
)
|
||||
|
||||
# Risk register updates
|
||||
risk_updates = {
|
||||
"R1": ("Mostly mitigated", "Envelope restored in tree; prove clean-runner CI/smoke."),
|
||||
"R2": ("Open (CI policy)", "SCA gate in CI; clear critical/high on clean runner."),
|
||||
"R3": ("Mitigated in code", "ADR-001; containerService fail-closed; UI out-of-scope."),
|
||||
"R4": ("Mitigated in code", "api-worker + DB leases + Redis publish; prove multi-replica."),
|
||||
"R5": ("Mitigated in code", "Redis rate-limit and idempotency stores."),
|
||||
"R6": ("Mitigated in code", "Schedules on worker with Redis leader lock."),
|
||||
"R7": ("Mitigated in code", "FILE_STORAGE_DRIVER local|s3; prefer object storage in prod."),
|
||||
"R8": ("Partial", "Metrics/logs/runbooks in repo; drills still open."),
|
||||
"R9": ("Partial", "Privacy data map drafted; owners/DSAR/encryption plan open."),
|
||||
"R10": ("Mitigated in code", "packages/types planCatalog + homepage/API consumers + tests."),
|
||||
"R11": ("Mitigated in code", "OpenAPI coverage CI gate; /ready and /metrics documented."),
|
||||
"R12": ("Partial", "Billing/notification SoT documented; migration convergence ongoing."),
|
||||
"R13": ("Open / later", "Shared clients still a Phase 3+ concern."),
|
||||
"R14": ("Open / later", "Stabilize CI runners; bounded completion still evidence."),
|
||||
}
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
first = row.cells[0].text.strip()
|
||||
for rid, (status, action) in risk_updates.items():
|
||||
if first.startswith(rid + " ") or first.startswith(rid + "•") or first.startswith(rid + " ·") or first.startswith(rid + " •"):
|
||||
if len(row.cells) >= 4:
|
||||
ev = row.cells[2].text.strip()
|
||||
if "STATUS (12 Aug 2026)" not in ev:
|
||||
row.cells[2].text = f"{ev}\nSTATUS (12 Aug 2026): {status}"
|
||||
row.cells[3].text = action
|
||||
elif len(row.cells) >= 3:
|
||||
row.cells[2].text = f"STATUS (12 Aug 2026): {status}. {action}"
|
||||
|
||||
# Phase status annotations
|
||||
phase_status = {
|
||||
"Phase 0 — Evidence recovery": (
|
||||
"STATUS (12 Aug 2026): Application/envelope code largely complete; "
|
||||
"remaining is recorded CI green + Compose smoke evidence."
|
||||
),
|
||||
"Phase 1 — Correctness and shared state": (
|
||||
"STATUS (12 Aug 2026): Core code landed (worker, Redis stores, S3 driver, /ready, shutdown). "
|
||||
"Two-replica correctness evidence still open."
|
||||
),
|
||||
"Phase 2 — Operational control": (
|
||||
"STATUS (12 Aug 2026): Metrics, catalog, OpenAPI gate, backup smoke, privacy/SoT docs landed. "
|
||||
"Alert/restore/reconciliation drills still open."
|
||||
),
|
||||
"Phase 3 — Scale and assurance": (
|
||||
"STATUS (12 Aug 2026): Cross-tenant suite, soak/chaos scripts, canary/key/pen-test runbooks, "
|
||||
"S11–S13 fixes landed. Staging drills and pen-test report still open. RLS deferred (ADR-002)."
|
||||
),
|
||||
"Phase 4 — Selective evolution": (
|
||||
"STATUS (12 Aug 2026): Extraction gates documented (ADR-003). Default decision remains "
|
||||
"do not extract microservices until G1–G7 measurement passes."
|
||||
),
|
||||
}
|
||||
|
||||
paras = list(doc.paragraphs)
|
||||
for idx, p in enumerate(paras):
|
||||
for title, status in phase_status.items():
|
||||
if p.text.startswith(title):
|
||||
insert_after = idx
|
||||
for j in range(idx, min(idx + 12, len(paras))):
|
||||
if paras[j].text.startswith("Exit evidence"):
|
||||
insert_after = j
|
||||
break
|
||||
if insert_after + 1 < len(paras) and not paras[insert_after + 1].text.strip():
|
||||
set_runs_text(paras[insert_after + 1], status)
|
||||
elif paras[insert_after].text.startswith("Exit evidence"):
|
||||
set_runs_text(paras[insert_after], paras[insert_after].text + " " + status)
|
||||
else:
|
||||
set_runs_text(p, p.text + " — " + status)
|
||||
print("phase status:", title[:48])
|
||||
|
||||
# Claims not supportable — soft updates
|
||||
for p in doc.paragraphs:
|
||||
if p.text.startswith("CI-green or reproducibly deployable from the supplied package"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"CI-green or reproducibly deployable without recorded clean-runner evidence "
|
||||
"(envelope restored in tree, but attestation still required).",
|
||||
)
|
||||
if p.text.startswith("Complete OpenAPI coverage or a stable external API contract"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Perfect OpenAPI coverage (coverage gate exists; completeness still approximate "
|
||||
"and must stay green in CI).",
|
||||
)
|
||||
if p.text.startswith("Guaranteed email, push, SMS, or realtime notification delivery through the modeled outbox"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Guaranteed multi-channel notification delivery in production without worker soak "
|
||||
"evidence (dispatcher exists in code).",
|
||||
)
|
||||
if p.text.startswith("Per-tenant container isolation or functioning tenant container orchestration"):
|
||||
set_runs_text(
|
||||
p,
|
||||
"Per-tenant container isolation (explicitly disabled and out of production scope — ADR-001).",
|
||||
)
|
||||
|
||||
# --- Append full addendum ---
|
||||
doc.add_page_break()
|
||||
doc.add_heading("Addendum A — Implementation status (12 August 2026)", level=1)
|
||||
intro = doc.add_paragraph()
|
||||
intro.add_run(
|
||||
"This addendum updates Version 1.0 (9 August 2026) after execution of the production-readiness "
|
||||
"program against the management monorepo. It does not replace the original evidence-based findings; "
|
||||
"it records what has since been implemented in application code and in-repo runbooks, and what remains "
|
||||
"evidence-only before a production-ready claim."
|
||||
)
|
||||
|
||||
doc.add_heading("A.1 Verdict shift", level=2)
|
||||
doc.add_paragraph(
|
||||
"9 Aug 2026: capable beta / pre-scale; package incomplete; scale-critical paths process-local or missing."
|
||||
)
|
||||
doc.add_paragraph(
|
||||
"12 Aug 2026: late beta / hardening stage. Phases 0–4 application code and runbooks are complete. "
|
||||
"Investor “production ready” still requires recorded ops evidence (CI smoke, multi-replica proof, "
|
||||
"DR/alert drills, soak, pen-test)."
|
||||
)
|
||||
|
||||
doc.add_heading("A.2 Phase implementation summary", level=2)
|
||||
phases = [
|
||||
(
|
||||
"Phase 0 — Security & envelope",
|
||||
"Team/admin secret scrubbing; hashed invites; containers fail-closed (ADR-001); "
|
||||
"redirect/payment allowlists; employee email uniqueness; hash-only reset/verify tokens; "
|
||||
"env scrub; CI security:static + npm audit gate.",
|
||||
),
|
||||
(
|
||||
"Phase 1 — Replica-safe shared state",
|
||||
"Dedicated api-worker; outbox DB leases; Redis publish; Redis rate-limit & Carplace idempotency; "
|
||||
"FILE_STORAGE_DRIVER local|s3; GET /ready; graceful shutdown; ENABLE_EMBEDDED_JOBS gated.",
|
||||
),
|
||||
(
|
||||
"Phase 2 — Operational control",
|
||||
"Structured logs + GET /metrics; planCatalog single source; OpenAPI coverage CI; "
|
||||
"backup smoke + RPO/RTO checklist; privacy map; billing/notification source-of-truth docs.",
|
||||
),
|
||||
(
|
||||
"Phase 3 — Scale & assurance",
|
||||
"Cross-tenant isolation suite; S11 reviewToken scrub; S12 single-use payment consume; "
|
||||
"S13 fresh 2FA TTL; soak/chaos scripts; canary/key-rotation/pen-test docs; RLS deferred (ADR-002).",
|
||||
),
|
||||
(
|
||||
"Phase 4 — Selective evolution",
|
||||
"No microservice extract. ADR-003 + extraction gates G1–G7 + measurement templates. "
|
||||
"Default: stay modular monolith + api-worker.",
|
||||
),
|
||||
]
|
||||
for title, body in phases:
|
||||
p = doc.add_paragraph()
|
||||
run = p.add_run(title + ". ")
|
||||
run.bold = True
|
||||
p.add_run(body)
|
||||
|
||||
doc.add_heading("A.3 Architecture topology (current intent)", level=2)
|
||||
doc.add_paragraph(
|
||||
"Traefik → API × N → PostgreSQL; API and worker share Redis (rate limit, idempotency, pub/sub, locks); "
|
||||
"api-worker owns outbox/cron; files via local disk or S3/MinIO. Ops endpoints: /health, /ready, /metrics."
|
||||
)
|
||||
|
||||
doc.add_heading("A.4 Security findings S1–S15", level=2)
|
||||
doc.add_paragraph(
|
||||
"Application findings S1–S15 from the follow-on security review are fixed or documented in code "
|
||||
"(including S9 proxy-trust documentation, S11–S13). Independent penetration testing remains an open "
|
||||
"Phase 3 evidence item. See docs/design/SECURITY_DESIGN_UPDATES.md and "
|
||||
"docs/ops/RentalDriveGo_Production_Readiness_Plan.md §3."
|
||||
)
|
||||
|
||||
doc.add_heading("A.5 Remaining evidence before production-ready claim", level=2)
|
||||
for item in [
|
||||
"Clean-runner: npm ci → generate → lint → type-check → test → build with CI green and SCA clear/exceptioned.",
|
||||
"Compose smoke: two API replicas + worker + Postgres + Redis; booking/payment/notification/file correctness.",
|
||||
"Phase 2 drills: staging alert fired once; dated restore meeting RPO/RTO; provider reconciliation sample; privacy owners.",
|
||||
"Phase 3 drills: soak + failure injection; pen-test report under security-reports/; canary or rollback record; key-rotation drill.",
|
||||
"Phase 4: fill measurement template; default decision remains do not extract.",
|
||||
]:
|
||||
doc.add_paragraph(item, style="List Bullet")
|
||||
|
||||
doc.add_heading("A.6 Design documentation pointers", level=2)
|
||||
for item in [
|
||||
"docs/design/README.md — index of production-readiness design updates",
|
||||
"docs/design/PRODUCTION_READINESS_IMPLEMENTED.md — phase-by-phase landed work",
|
||||
"docs/design/RUNTIME_AND_OPS_SURFACE.md — worker, metrics, env knobs",
|
||||
"docs/ops/RentalDriveGo_Production_Readiness_Plan.md — living execution plan and exit checkboxes",
|
||||
"docs/ADR-001-disable-per-tenant-containers.md",
|
||||
"docs/ADR-002-defer-postgres-rls.md",
|
||||
"docs/ADR-003-defer-service-extraction.md",
|
||||
]:
|
||||
doc.add_paragraph(item, style="List Bullet")
|
||||
|
||||
doc.add_paragraph(
|
||||
"End of Addendum A. Original sections 1–17 remain the baseline diligence narrative from 9 August 2026, "
|
||||
"as amended inline above where statuses were refreshed."
|
||||
)
|
||||
|
||||
doc.save(str(src))
|
||||
print("saved", src, "size", src.stat().st_size)
|
||||
@@ -0,0 +1,501 @@
|
||||
# RentalDriveGo Manual UI Test Plan
|
||||
|
||||
**Version:** v1.0
|
||||
**Scope:** Phase 16 unified UI across homepage, Carplace/storefront, renter area, operator dashboard, platform admin, authentication pages, localization, theming, responsiveness, accessibility, and cross-app navigation.
|
||||
**Repository baseline:** `RentalDriveGo_Phase16_Dashboard_Admin_Unified_v1.0.zip`
|
||||
**Test type:** Manual functional UI, visual consistency, accessibility spot checks, responsive behavior, localization, and regression testing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Verify that all public and authenticated RentalDriveGo applications use the Phase 16 homepage design system consistently and remain usable across English, French, Arabic RTL, light mode, dark mode, desktop, tablet, and mobile.
|
||||
|
||||
The testing must prove three things:
|
||||
|
||||
1. The new design is applied consistently across all major pages.
|
||||
2. Existing user flows still work after the visual refactor.
|
||||
3. Shared navbar, footer, dashboard shell, admin shell, forms, tables, cards, and page headings behave correctly across contexts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Applications in scope
|
||||
|
||||
| Area | Application | Manual coverage required |
|
||||
|---|---|---|
|
||||
| Marketing website | `homepage` | Public homepage, navigation, language switcher, theme switcher, CTAs, demo flow, responsive layout, localized routes |
|
||||
| Marketplace / Carplace | `storefront` | Public marketplace, explore/search, company pages, vehicle detail, booking form, footer pages, renter auth and renter account pages |
|
||||
| Operator dashboard | `dashboard` | Public auth pages, dashboard shell, all operational routes, forms, tables, detail pages, modals, billing, settings, print surfaces |
|
||||
| Platform admin | `admin` | Admin auth, admin shell, all admin management routes, company management, pricing, billing, containers, menu/config pages |
|
||||
| API-connected behavior | `api` through UI | Confirm UI states for loading, success, validation errors, authorization errors, and network failures where available |
|
||||
|
||||
---
|
||||
|
||||
## 3. Test environments
|
||||
|
||||
Run manual UI testing in at least these environments:
|
||||
|
||||
| Environment | Required? | Notes |
|
||||
|---|---:|---|
|
||||
| Local development | Yes | Primary smoke and visual review environment |
|
||||
| Staging / preview deployment | Yes | Required before production release |
|
||||
| Production-like deployment | Recommended | Use production build settings, HTTPS, real domains, and realistic API URLs |
|
||||
|
||||
Do not approve release from local testing only. That would be optimism wearing a fake mustache.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browser and device matrix
|
||||
|
||||
### Desktop browsers
|
||||
|
||||
| Browser | OS | Priority |
|
||||
|---|---|---:|
|
||||
| Chrome latest | Windows | P0 |
|
||||
| Edge latest | Windows | P0 |
|
||||
| Safari latest | macOS | P1 |
|
||||
| Firefox latest | Windows or macOS | P1 |
|
||||
|
||||
### Mobile and tablet
|
||||
|
||||
| Device class | Width target | Priority |
|
||||
|---|---:|---:|
|
||||
| Small mobile | 360 x 800 | P0 |
|
||||
| Large mobile | 390 x 844 | P0 |
|
||||
| Tablet | 768 x 1024 | P1 |
|
||||
| Small laptop | 1280 x 800 | P0 |
|
||||
| Desktop | 1440 x 900 | P0 |
|
||||
| Wide desktop | 1920 x 1080 | P1 |
|
||||
|
||||
Use real devices where possible for mobile navigation, keyboard behavior, touch target size, and scroll locking. Browser dev tools are useful, but they are not a phone, no matter how hard the viewport dropdown tries.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test users and roles
|
||||
|
||||
Prepare these accounts before testing:
|
||||
|
||||
| Role | Needed for |
|
||||
|---|---|
|
||||
| Public visitor | Homepage, marketplace, footer pages, sign-in/create-account flows |
|
||||
| Renter | Carplace renter dashboard, profile, saved companies, notifications |
|
||||
| Operator / agency admin | Main dashboard, reservations, contracts, fleet, customers, team, billing, settings |
|
||||
| Operator team member with limited permissions | Permission visibility and blocked routes |
|
||||
| Platform super admin | Admin dashboard and platform management pages |
|
||||
| Invalid/expired user | Auth error states, reset password, invite, verification edge cases |
|
||||
|
||||
---
|
||||
|
||||
## 6. Required test data
|
||||
|
||||
Create or seed the following before full UI testing:
|
||||
|
||||
| Data | Minimum requirement |
|
||||
|---|---|
|
||||
| Companies/agencies | At least 3 active companies with different logos, statuses, locales, and subscription states |
|
||||
| Vehicles | At least 10 vehicles across available, reserved, maintenance, inactive, and incomplete states |
|
||||
| Reservations | Draft, confirmed, active, overdue, cancelled, completed, online-requested |
|
||||
| Contracts | At least one printable contract with customer, vehicle, dates, photos, and charges |
|
||||
| Customers/renters | Customers with complete, partial, and missing information |
|
||||
| Team members | Admin, manager, agent, inactive user, invited user |
|
||||
| Reviews/complaints | Open, resolved, escalated, empty-state capable |
|
||||
| Billing/subscription | Active plan, trial, past due, cancelled or expired state where possible |
|
||||
| Notifications | Read, unread, action-required, empty state |
|
||||
| Admin data | Companies, renters, platform admins, pricing plans, containers, audit logs, menu entries, site config |
|
||||
| Localization content | English, French, Arabic strings for public and authenticated areas |
|
||||
|
||||
---
|
||||
|
||||
## 7. Global design acceptance criteria
|
||||
|
||||
Every page must satisfy these checks:
|
||||
|
||||
- Uses Phase 16 blue/orange token system.
|
||||
- Blue is used for primary operational actions.
|
||||
- Orange is reserved for conversion, emphasis, selected states, focus, or warnings.
|
||||
- Page background, panels, cards, forms, tables, badges, and shadows match the homepage visual language.
|
||||
- No old `FleetOS` branding appears anywhere.
|
||||
- No mixed brand names such as `RentalCarDrive`, `RentalDrive`, or legacy storefront naming appear in visible UI unless intentionally part of old data.
|
||||
- Light and dark mode both remain readable.
|
||||
- Arabic uses true RTL layout, not just Arabic text shoved into left-to-right furniture.
|
||||
- Public navbar and footer appear on standalone public/auth pages where expected.
|
||||
- Embedded auth pages using `embedded=1` do not show duplicated navbar/footer.
|
||||
- Mobile layouts avoid horizontal scrolling.
|
||||
- Buttons, links, inputs, selects, and controls have visible hover, active, disabled, loading, and focus-visible states.
|
||||
- Interactive controls are approximately 44 px minimum height where practical.
|
||||
- Tables, modals, drawers, menus, and dropdowns remain usable on mobile.
|
||||
- Loading, empty, success, validation-error, permission-error, and server-error states are visually consistent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Global navigation and layout tests
|
||||
|
||||
| ID | Area | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| NAV-001 | Public navbar | Open homepage desktop. Inspect logo, nav links, language switcher, theme switcher, CTAs. | Navbar matches Phase 16 design, no clipping, active/hover states visible. | P0 |
|
||||
| NAV-002 | Public mobile navbar | Open homepage at 360 px. Open and close mobile menu. | Menu opens cleanly, traps visual focus appropriately, scroll does not break, links are usable. | P0 |
|
||||
| NAV-003 | Public footer | Open homepage, marketplace home, sign-in, create account. | Shared footer appears consistently on standalone public pages. | P0 |
|
||||
| NAV-004 | Auth public layout | Open dashboard sign-in, sign-up, forgot password, reset password, verify email, onboarding. | Shared navbar/footer are visible unless `embedded=1` is present. | P0 |
|
||||
| NAV-005 | Embedded auth layout | Add `?embedded=1` to auth URLs. | Navbar/footer are hidden and page does not show nested chrome. | P0 |
|
||||
| NAV-006 | Dashboard shell | Log in as operator. Move through all dashboard routes. | Sidebar, topbar, active states, page width, and spacing remain consistent. | P0 |
|
||||
| NAV-007 | Admin shell | Log in as platform admin. Move through all admin routes. | Admin sidebar/topbar match dashboard design system, with admin-specific nav. | P0 |
|
||||
| NAV-008 | Mobile operational shell | Open dashboard/admin at mobile width. Open sidebar/menu. | Drawer works, content is not hidden behind topbar, no horizontal scrolling. | P0 |
|
||||
| NAV-009 | Logout/account controls | Use user/account menu in dashboard/admin. | Menu opens, closes, keyboard works, logout/account actions are visible. | P1 |
|
||||
| NAV-010 | Active route state | Visit every sidebar route. | Correct nav item uses active state and accessible current-page indication. | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Homepage manual tests
|
||||
|
||||
Test locales: `/en`, `/fr`, `/ar`.
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| HOME-001 | Homepage load | Open each locale homepage. | Hero, proof section, comparison, workflow, feature sections, pricing/CTA areas load without broken layout. | P0 |
|
||||
| HOME-002 | Hero CTAs | Click primary demo CTA and secondary tour/explore CTA if present. | Correct dialog, route, or section opens. No dead links. | P0 |
|
||||
| HOME-003 | Demo dialog/form | Open demo request flow. Submit empty form, invalid email, then valid data. | Validation messages are clear; valid submission shows success or expected API result. | P0 |
|
||||
| HOME-004 | Language switcher | Switch EN to FR to AR and back. | User remains on matching route; copy changes; Arabic direction flips to RTL. | P0 |
|
||||
| HOME-005 | Theme switcher | Switch light, dark, system. Refresh page. | Theme applies consistently and persists if persistence is supported. | P0 |
|
||||
| HOME-006 | Header scroll behavior | Scroll through page. | Sticky/header behavior is stable and does not obscure content. | P1 |
|
||||
| HOME-007 | Public content pages | Open localized slug pages available in the site. | Header/footer render; not-found and loading states are localized and styled. | P1 |
|
||||
| HOME-008 | Component lab | Open component lab if enabled. | Components render without layout breakage and match tokens. | P2 |
|
||||
| HOME-009 | SEO/basic metadata | Inspect tab title, favicon, social preview if available. | Branding is RentalDriveGo and no old brand appears. | P2 |
|
||||
| HOME-010 | Public accessibility pass | Keyboard through homepage from top to bottom. | Focus order is logical, focus rings visible, menus/dialogs usable. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Carplace / storefront public tests
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| CP-001 | Marketplace home | Open storefront public home. | Uses Phase 16 navbar/footer and visual tokens. | P0 |
|
||||
| CP-002 | Explore page | Open `/explore`. Search/filter if controls exist. | Filters are usable; results grid/cards align with design; empty state works. | P0 |
|
||||
| CP-003 | Company workspace page | Open company workspace creation/info page. | Public layout, CTA hierarchy, and form controls are consistent. | P0 |
|
||||
| CP-004 | Company profile | Open `/explore/[slug]` for at least 3 companies. | Branding, vehicles, contact/booking areas load correctly. | P0 |
|
||||
| CP-005 | Vehicle detail | Open `/explore/[slug]/vehicles/[id]`. | Vehicle photos/details/pricing/availability render without overflow. | P0 |
|
||||
| CP-006 | Booking form | Submit booking with empty, invalid, and valid fields. | Validation, date handling, price display, and success/error states are clear. | P0 |
|
||||
| CP-007 | Public sign-in | Open storefront sign-in. | Styling matches public auth design and routes correctly. | P0 |
|
||||
| CP-008 | Policy pages | Open EN/FR/AR privacy and terms pages. | Footer/header are consistent; copy direction is correct; long content is readable. | P1 |
|
||||
| CP-009 | Footer content pages | Open footer dynamic pages. | Content loads; no broken layout or missing footer. | P1 |
|
||||
| CP-010 | Marketplace mobile | Test home, explore, company, vehicle detail at 360 px. | Cards stack properly, no horizontal scroll, booking CTA remains usable. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 11. Renter account tests
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| RENT-001 | Renter sign-in | Open renter sign-in and test invalid credentials. | Validation/error state is styled and readable. | P0 |
|
||||
| RENT-002 | Renter sign-up | Create account with missing, invalid, and valid data. | Form states and success path are clear. | P0 |
|
||||
| RENT-003 | Renter dashboard | Log in as renter and open dashboard. | Shell is consistent with marketplace/renter context; key data appears. | P0 |
|
||||
| RENT-004 | Saved companies | Save and remove company where supported. | State updates visually and persists after refresh if expected. | P1 |
|
||||
| RENT-005 | Renter profile | Update profile fields. | Field styling, validation, save state, and success message are consistent. | P0 |
|
||||
| RENT-006 | Renter notifications | Open notifications with read/unread data. | Notification cards/states are readable in light/dark. | P1 |
|
||||
| RENT-007 | Permission boundary | Try accessing renter pages while logged out. | Redirect or guard behavior is correct. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 12. Dashboard public authentication tests
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| AUTH-001 | Sign in | Open dashboard sign-in. Test empty, invalid, and valid credentials. | Public navbar/footer show; errors are clear; success redirects to dashboard. | P0 |
|
||||
| AUTH-002 | Create account | Open sign-up/create account. Test required fields and valid registration. | Form matches design; validation and success path are clear. | P0 |
|
||||
| AUTH-003 | Forgot password | Request reset with empty, invalid, unknown, and valid email. | Safe, clear messaging; no layout break. | P0 |
|
||||
| AUTH-004 | Reset password | Open reset page with missing, invalid, expired, and valid token. | Correct error/success states and password field behavior. | P0 |
|
||||
| AUTH-005 | Verify email | Open valid, invalid, and expired verification states. | Styled message and next action are clear. | P1 |
|
||||
| AUTH-006 | Onboarding | Open onboarding flow as invited/new user. | Layout, steps, forms, and final redirect work. | P0 |
|
||||
| AUTH-007 | Accept invite | Open invitation acceptance with valid/expired invite. | Correct messaging and route behavior. | P0 |
|
||||
| AUTH-008 | Embedded auth | Open each auth URL with `?embedded=1`. | No duplicated navbar/footer; form remains centered and usable. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Operator dashboard tests
|
||||
|
||||
### Dashboard overview
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| DASH-001 | Overview | Open dashboard home. | Hero/heading, metrics, quick actions, and recent activity match Phase 16 style. | P0 |
|
||||
| DASH-002 | Empty state | Use company/account with minimal data. | Empty panels are intentional, helpful, and styled. | P1 |
|
||||
| DASH-003 | Loading state | Throttle network and reload. | Skeleton/loading states do not cause layout jumps or unreadable text. | P1 |
|
||||
|
||||
### Reservations
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| RES-001 | Reservation list | Open reservations page. Search/filter/sort if available. | Table/cards are readable and responsive; statuses are clear. | P0 |
|
||||
| RES-002 | New reservation | Create reservation with missing, invalid, and valid data. | Form fields, validation, customer/vehicle/date selection, and save states work. | P0 |
|
||||
| RES-003 | Reservation detail | Open confirmed, active, cancelled, completed reservation. | Detail layout, actions, status badges, panels, and timeline are consistent. | P0 |
|
||||
| RES-004 | Reservation photos | Upload/view/remove photos if supported. | Upload UI, previews, errors, and mobile behavior work. | P1 |
|
||||
| RES-005 | Vehicle condition | Complete damage/condition inspection if supported. | Cards, controls, required fields, and summary are usable. | P1 |
|
||||
|
||||
### Contracts
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| CON-001 | Contract list | Open contracts page and inspect table/actions. | Table style, filters, and empty states match design. | P0 |
|
||||
| CON-002 | Contract detail | Open contract detail. | Panels, customer/vehicle/rate sections, and actions align with design. | P0 |
|
||||
| CON-003 | Print contract | Use print preview. | Printable contract remains document-like and does not include broken app chrome. | P0 |
|
||||
|
||||
### Fleet
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| FLEET-001 | Fleet list | Open fleet page with many vehicles. | Cards/table/grid are readable; status colors include text/icons. | P0 |
|
||||
| FLEET-002 | Vehicle detail | Open vehicle detail for available, reserved, maintenance vehicles. | Detail panels, pricing, availability, and actions are consistent. | P0 |
|
||||
| FLEET-003 | Vehicle calendar | Open calendar view if present. | Calendar cells, selected date, availability, and overflow are usable. | P1 |
|
||||
| FLEET-004 | Vehicle pricing | Edit pricing rules if supported. | Inputs, tables, add/remove states, and save behavior are clear. | P1 |
|
||||
|
||||
### Customers and team
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| CUST-001 | Customers | Open customers page, search, inspect profiles/actions. | Table/cards, empty states, and row actions match design. | P0 |
|
||||
| TEAM-001 | Team list | Open team page with active/invited/inactive members. | Roles/statuses are clear and not color-only. | P0 |
|
||||
| TEAM-002 | Invite modal | Open invite member modal and submit invalid/valid data. | Modal, focus, validation, and success/error states work. | P0 |
|
||||
| TEAM-003 | Edit member modal | Change role/permissions where allowed. | Permission matrix is readable and keyboard usable. | P1 |
|
||||
| TEAM-004 | Limited permission user | Log in as limited member. | Restricted nav/actions are hidden or disabled consistently. | P0 |
|
||||
|
||||
### Online reservations, reviews, complaints, offers
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| OPS-001 | Online reservations | Open page with pending/accepted/rejected requests. | Statuses, action buttons, details, and empty states are consistent. | P0 |
|
||||
| OPS-002 | Reviews | Open reviews page with data and empty state. | Review cards/table and response controls are readable. | P1 |
|
||||
| OPS-003 | Complaints | Open complaints page with open/resolved items. | Priority/status treatment is accessible and consistent. | P1 |
|
||||
| OPS-004 | Offers | Create/edit/disable an offer where supported. | Orange is used only for promotional emphasis; operational saves are blue. | P1 |
|
||||
|
||||
### Reports, notifications, billing, subscription, settings
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| MGT-001 | Reports | Open reports page with and without data. | Charts/tables/cards align and stay readable in dark mode. | P1 |
|
||||
| MGT-002 | Notifications | Open notifications, mark read/unread if supported. | Read/unread states are visible and accessible. | P1 |
|
||||
| MGT-003 | Billing | Open billing page with active/past-due/trial states. | Plan cards, invoices, payment CTAs, and warnings are clear. | P0 |
|
||||
| MGT-004 | Subscription | Open subscription page and test plan state display. | Current plan, upgrade/downgrade/cancel paths are visually clear. | P0 |
|
||||
| MGT-005 | Settings | Open all settings sections. | Sections are organized, forms are consistent, and save states work. | P0 |
|
||||
| MGT-006 | Dashboard route refresh | Refresh every dashboard page directly. | No route loses layout, auth, locale, or styling after refresh. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 14. Platform admin tests
|
||||
|
||||
### Admin authentication
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| AD-AUTH-001 | Admin login | Open admin login and test invalid/valid credentials. | Page matches design; errors are readable; success redirects to admin dashboard. | P0 |
|
||||
| AD-AUTH-002 | Admin forgot/reset password | Test forgot and reset flows. | Validation and success/error states are clear. | P1 |
|
||||
| AD-AUTH-003 | Auth redirect | Open admin root and auth redirect routes. | Correct redirect behavior for logged-in/logged-out users. | P0 |
|
||||
|
||||
### Admin dashboard and management pages
|
||||
|
||||
| ID | Page/feature | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| AD-001 | Admin overview | Open admin dashboard home. | Hero/metrics/actions match Phase 16 style. | P0 |
|
||||
| AD-002 | Companies list | Open companies page with many companies. Search/filter/sort where available. | Table/cards, statuses, and actions are consistent. | P0 |
|
||||
| AD-003 | Company detail | Open company detail for active, trial, suspended/past-due companies. | Panels, admin actions, metadata, and billing/status sections are clear. | P0 |
|
||||
| AD-004 | Renters | Open renters page. Inspect search/filter/detail actions. | Layout and status treatment are consistent. | P1 |
|
||||
| AD-005 | Admin users | Open admin users page. Create/edit/disable if supported. | Forms/modals/buttons use shared design and permissions are clear. | P0 |
|
||||
| AD-006 | Billing | Open admin billing page. | Invoices/subscriptions/payment states are readable and consistent. | P0 |
|
||||
| AD-007 | Pricing | Open pricing management. Add/edit plans where supported. | Form fields, conversion accents, and save buttons follow token rules. | P0 |
|
||||
| AD-008 | Containers | Open containers page. | Container status, controls, tables, and warnings are clear. | P1 |
|
||||
| AD-009 | Audit logs | Open audit logs with dense data. Search/filter if available. | Dense table remains readable, scrollable, and accessible. | P1 |
|
||||
| AD-010 | Notifications | Open admin notifications page. | Creation/list/status/empty states are consistent. | P1 |
|
||||
| AD-011 | Menu management | Open menu management page. Edit/reorder/toggle items if supported. | Drag/drop or controls work and are usable on mobile. | P1 |
|
||||
| AD-012 | Site config | Open site configuration page. Edit fields and save. | Field styling, section hierarchy, validation, and success states work. | P0 |
|
||||
| AD-013 | Admin mobile | Test all admin pages at 360 px. | Drawer, tables, modals, and forms remain usable without horizontal page scroll. | P0 |
|
||||
| AD-014 | Admin route refresh | Refresh every admin page directly. | No page loses shell, auth state, or styling. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 15. Localization and RTL tests
|
||||
|
||||
Run these checks across homepage, Carplace, dashboard auth, dashboard, and admin where language switching is available.
|
||||
|
||||
| ID | Scenario | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| LOC-001 | English | Use English UI. | Copy fits controls; dates/numbers are readable. | P0 |
|
||||
| LOC-002 | French | Switch to French. | Longer French labels do not overflow buttons, cards, tables, or nav. | P0 |
|
||||
| LOC-003 | Arabic RTL | Switch to Arabic. | Layout direction flips; sidebar, nav, icons, paddings, and text alignment are RTL-aware. | P0 |
|
||||
| LOC-004 | Mixed content | View emails, names, phone numbers, prices, and vehicle names in Arabic UI. | LTR data remains readable inside RTL layout. | P1 |
|
||||
| LOC-005 | Mobile Arabic | Test public and authenticated mobile pages in Arabic. | Drawers open from the correct side; content does not clip. | P0 |
|
||||
| LOC-006 | Missing translation | Force/identify missing string where possible. | No raw translation keys leak into UI. | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 16. Theme tests
|
||||
|
||||
| ID | Scenario | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| THEME-001 | Light mode | Switch to light mode and inspect all major routes. | Contrast, shadows, borders, and panels match Phase 16. | P0 |
|
||||
| THEME-002 | Dark mode | Switch to dark mode and inspect all major routes. | Text, inputs, badges, tables, dialogs, and menus remain readable. | P0 |
|
||||
| THEME-003 | System mode | Set system theme and change OS preference. | App follows system setting if supported. | P1 |
|
||||
| THEME-004 | Theme persistence | Change theme, refresh, open new tab. | Theme persists consistently where expected. | P1 |
|
||||
| THEME-005 | Dark mode modals/dropdowns | Open menus, dialogs, date pickers, selects. | Overlays do not appear with mismatched light/dark styling. | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 17. Responsive testing checklist
|
||||
|
||||
For each major route group, test 360 px, 390 px, 768 px, 1280 px, and 1440 px widths.
|
||||
|
||||
| ID | Check | Expected result |
|
||||
|---|---|---|
|
||||
| RESP-001 | No horizontal page scroll | Content fits viewport except intentional internal table scrolling. |
|
||||
| RESP-002 | Sidebar/drawer behavior | Desktop sidebar becomes usable mobile drawer. |
|
||||
| RESP-003 | Topbar behavior | Topbar does not cover content or controls. |
|
||||
| RESP-004 | Tables | Tables either stack, scroll inside a panel, or remain readable. |
|
||||
| RESP-005 | Forms | Labels, inputs, helper text, errors, and buttons fit. |
|
||||
| RESP-006 | Modals | Dialogs fit viewport and scroll internally if needed. |
|
||||
| RESP-007 | Cards/grids | Cards stack with reasonable spacing. |
|
||||
| RESP-008 | Footer | Footer columns collapse cleanly. |
|
||||
| RESP-009 | CTAs | Primary actions remain reachable without awkward scrolling. |
|
||||
| RESP-010 | Touch targets | Controls are large enough for touch interaction. |
|
||||
|
||||
---
|
||||
|
||||
## 18. Accessibility spot-check plan
|
||||
|
||||
This is not a full WCAG audit, but these checks are mandatory before release.
|
||||
|
||||
| ID | Check | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| A11Y-001 | Keyboard navigation | Use Tab/Shift+Tab through every major page. | Focus order is logical and visible. | P0 |
|
||||
| A11Y-002 | Skip/repeated navigation | Test repeated nav areas. | User can reach main content without excessive tabbing where supported. | P1 |
|
||||
| A11Y-003 | Menus/dialogs | Open and close nav menus, dropdowns, modals. | Focus enters, remains usable, and returns predictably. | P0 |
|
||||
| A11Y-004 | Forms | Trigger validation errors. | Errors are associated with fields and readable. | P0 |
|
||||
| A11Y-005 | Color contrast | Inspect text, badges, disabled states, links in light/dark. | Text and states are legible. | P0 |
|
||||
| A11Y-006 | Color-only status | Inspect statuses. | Status is not communicated by color alone. | P0 |
|
||||
| A11Y-007 | Zoom | Test browser zoom at 200%. | Content remains usable without major clipping. | P1 |
|
||||
| A11Y-008 | Reduced motion | Enable reduced motion. | No essential interaction depends on animation. | P1 |
|
||||
| A11Y-009 | Forced colors | Test high-contrast/forced-colors mode if available. | Focus, text, and controls remain usable. | P1 |
|
||||
| A11Y-010 | Screen reader smoke | Use VoiceOver/NVDA on key pages. | Headings, buttons, form labels, links, and menus are understandable. | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 19. Error, empty, loading, and permission states
|
||||
|
||||
Verify these states across homepage forms, Carplace booking, dashboard pages, and admin pages.
|
||||
|
||||
| ID | State | How to test | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| STATE-001 | Loading | Throttle network or reload API-heavy pages. | Loading UI uses shared styling and avoids layout collapse. | P0 |
|
||||
| STATE-002 | Empty data | Use account with no records. | Empty state explains what to do next and uses matching card/panel style. | P0 |
|
||||
| STATE-003 | Validation error | Submit required forms empty or invalid. | Error messages are clear, close to fields, and accessible. | P0 |
|
||||
| STATE-004 | Server error | Simulate 500/API failure where possible. | Error message is readable and not a raw stack trace. | P0 |
|
||||
| STATE-005 | Unauthorized | Access dashboard/admin while logged out. | Redirect or blocked state is correct. | P0 |
|
||||
| STATE-006 | Forbidden | Access admin-only route with non-admin user. | User sees safe forbidden state or redirect. | P0 |
|
||||
| STATE-007 | Not found | Open invalid public, dashboard, storefront, and admin URLs. | Styled not-found behavior, no broken shell. | P1 |
|
||||
| STATE-008 | Offline | Disable network after page load and perform action. | User receives clear failure feedback. | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 20. Cross-app route and brand consistency tests
|
||||
|
||||
| ID | Check | Steps | Expected result | Priority |
|
||||
|---|---|---|---|---:|
|
||||
| XAPP-001 | Homepage to dashboard auth | Use homepage CTA that leads to sign-in/create account. | Correct app opens with public navbar/footer and same design language. | P0 |
|
||||
| XAPP-002 | Homepage to Carplace | Use marketplace/explore links if present. | Carplace opens with shared public design. | P0 |
|
||||
| XAPP-003 | Dashboard to public site | Use logo/help/home links where present. | Correct destination; no old domain/path if not intended. | P1 |
|
||||
| XAPP-004 | Admin to company detail | From admin companies, open company detail. | Route works and design remains consistent. | P0 |
|
||||
| XAPP-005 | Branding | Search visible UI for old names. | Only `RentalDriveGo` and intentional `Carplace` marketplace naming appear. | P0 |
|
||||
| XAPP-006 | Favicon/logo assets | Inspect browser tab and logos across apps. | Assets are not broken, stretched, or legacy-branded. | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 21. Manual regression checklist before release
|
||||
|
||||
Run this checklist after every UI/design change:
|
||||
|
||||
- Homepage loads in EN, FR, AR.
|
||||
- Public navbar works on desktop and mobile.
|
||||
- Public footer appears on homepage, Carplace, sign-in, create-account, forgot-password, reset-password, verify-email, onboarding.
|
||||
- Embedded auth hides navbar/footer with `embedded=1`.
|
||||
- Dashboard login works.
|
||||
- Dashboard overview loads.
|
||||
- Dashboard reservations list, new reservation, and reservation detail load.
|
||||
- Dashboard contracts list, contract detail, and print preview work.
|
||||
- Dashboard fleet list and vehicle detail load.
|
||||
- Dashboard customers, team, billing, subscription, reports, notifications, settings load.
|
||||
- Admin login works.
|
||||
- Admin overview, companies, company detail, renters, admin users, billing, pricing, containers, audit logs, notifications, menu management, site config load.
|
||||
- Carplace home, explore, company page, vehicle page, booking form, renter dashboard load.
|
||||
- Light/dark mode checked on at least homepage, dashboard overview, admin companies, Carplace explore.
|
||||
- Arabic RTL checked on at least homepage, dashboard overview, admin companies, Carplace explore.
|
||||
- Mobile checked on at least homepage, dashboard overview, admin companies, Carplace vehicle detail.
|
||||
- Keyboard navigation checked on homepage, dashboard sign-in, reservation form, admin companies.
|
||||
- No visible old branding.
|
||||
- No horizontal scroll at mobile width.
|
||||
- No raw errors, stack traces, broken images, or unstyled default controls.
|
||||
|
||||
---
|
||||
|
||||
## 22. Defect reporting format
|
||||
|
||||
Every UI defect should include:
|
||||
|
||||
```text
|
||||
Title:
|
||||
Environment:
|
||||
App:
|
||||
Route:
|
||||
Role/account:
|
||||
Browser/device:
|
||||
Language:
|
||||
Theme:
|
||||
Viewport size:
|
||||
Steps to reproduce:
|
||||
Expected result:
|
||||
Actual result:
|
||||
Severity:
|
||||
Screenshot/video:
|
||||
Console errors:
|
||||
Network/API errors:
|
||||
Regression? yes/no:
|
||||
```
|
||||
|
||||
Severity definitions:
|
||||
|
||||
| Severity | Meaning | Example |
|
||||
|---|---|---|
|
||||
| S0 Blocker | User cannot complete a core flow | Cannot sign in, dashboard crashes, booking cannot submit |
|
||||
| S1 Critical | Major page/flow broken or misleading | Wrong layout on mobile, save action hidden, admin table unusable |
|
||||
| S2 Major | Important UI defect with workaround | Modal overflow, dark mode unreadable badge, RTL alignment bug |
|
||||
| S3 Minor | Cosmetic or low-impact issue | Slight spacing mismatch, non-critical hover inconsistency |
|
||||
| S4 Trivial | Polish only | Tiny copy or icon alignment issue |
|
||||
|
||||
---
|
||||
|
||||
## 23. Release exit criteria
|
||||
|
||||
Manual UI testing may be accepted when:
|
||||
|
||||
- All P0 tests pass.
|
||||
- No S0 or S1 defects remain open.
|
||||
- S2 defects have approved fixes or explicit release acceptance.
|
||||
- Homepage, Carplace, dashboard, and admin have been tested in light, dark, and Arabic RTL.
|
||||
- Mobile smoke tests pass at 360 px width.
|
||||
- Keyboard navigation smoke tests pass for public auth, dashboard, and admin.
|
||||
- Public navbar/footer behavior is verified on all standalone public/auth pages.
|
||||
- Embedded auth behavior is verified with `embedded=1`.
|
||||
- Visual consistency review confirms Phase 16 design language across public and authenticated pages.
|
||||
- Stakeholder signs off with screenshots or recordings attached for the main flows.
|
||||
|
||||
---
|
||||
|
||||
## 24. Suggested execution order
|
||||
|
||||
1. Smoke test all apps and authentication first.
|
||||
2. Test homepage and public navigation/footer.
|
||||
3. Test Carplace public marketplace and renter flows.
|
||||
4. Test operator dashboard routes.
|
||||
5. Test platform admin routes.
|
||||
6. Run localization and RTL passes.
|
||||
7. Run dark mode pass.
|
||||
8. Run mobile pass.
|
||||
9. Run accessibility spot checks.
|
||||
10. Re-test fixed defects and complete release checklist.
|
||||
|
||||
This order catches structural failures early. Testing every button color before confirming login works.
|
||||
@@ -0,0 +1,492 @@
|
||||
# RentalDriveGo — Plan to Reach Production Ready
|
||||
|
||||
**Purpose of this document:** This is the **execution plan to take the current codebase to production ready**. It is not a feature roadmap and not an architecture rewrite. Success is measured only by the definition in §1 and the checkpoint in §13.
|
||||
|
||||
**Status:** Phases 0–4 **application code / runbooks complete** (12 Aug 2026). Remaining work is **ops evidence** (CI smoke, restore/alert drills, pen-test, soak) — not missing features. Phase 4 correctly does **not** extract microservices (ADR-003).
|
||||
**Code root:** `D:\1\management`
|
||||
**Last re-verified against source:** 12 Aug 2026 (Phases 0–4 code pass)
|
||||
**Inputs:** Live repo state + *Technical Architecture & Investor Due Diligence* (v1.0, 9 Aug 2026) + prior hardening reports under `docs/`
|
||||
**Architecture decision:** Keep the modular monolith. Do not split services until Phase 3 load/ownership evidence justifies it.
|
||||
|
||||
### How this plan works
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
now[Current_beta_pre_scale] --> sec[Close_security_P0]
|
||||
sec --> p0[Finish_Phase0_evidence]
|
||||
p0 --> p1[Phase1_replica_safe]
|
||||
p1 --> p2[Phase2_operate_recover]
|
||||
p2 --> p3[Phase3_prove_under_load]
|
||||
p3 --> prod[Production_ready_gate]
|
||||
```
|
||||
|
||||
| Stage | Outcome |
|
||||
|-------|---------|
|
||||
| Security P0 + Phase 0 close | Safe enough to run a controlled environment; envelope proven |
|
||||
| Phase 1 | Safe to run **two API replicas** + worker |
|
||||
| Phase 2 | Operable: observe, backup/restore, commercial/privacy baselines |
|
||||
| Phase 3 + §13 checklist | **Production ready** — claims allowed only after exit evidence |
|
||||
|
||||
---
|
||||
|
||||
## 1. Definition of “production ready” (the finish line)
|
||||
|
||||
The project is **production ready** only when all of the following are true and evidenced:
|
||||
|
||||
1. Install, generate, lint, type-check, test, and build from a clean checkout
|
||||
2. Deploy a known topology (API, frontends, PostgreSQL, Redis, storage, worker)
|
||||
3. Run **two API replicas** without duplicate bookings, duplicate cron side effects, or missing files
|
||||
4. Deliver notifications that were written to the outbox (email + realtime) without multi-replica double-send
|
||||
5. Detect failure, restore data within agreed RPO/RTO, and show no unresolved critical/high dependency vulnerabilities outside a dated exception
|
||||
6. Ship with Critical/High application-security findings closed (§3)
|
||||
7. §13 diligence checkpoint items are checked off with retained evidence
|
||||
|
||||
Until then, the accurate label remains **capable beta / pre-scale**, not production SaaS.
|
||||
|
||||
**Out of scope for this plan:** new marketplace features, KYC/OCR, per-tenant Docker isolation, microservices rewrite, marketing-only work.
|
||||
|
||||
### Why this path
|
||||
|
||||
> The core product and data architecture exist; the next milestone is to convert that breadth into a reproducible, secure, observable, horizontally safe production system.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (re-verified after project update)
|
||||
|
||||
### 2.1 Progress since the original diligence snapshot
|
||||
|
||||
| Area | Previous gap | Status now | Evidence |
|
||||
|------|--------------|------------|----------|
|
||||
| Turbo pipeline | Missing | **Done** | `turbo.json` (build/dev/lint/type-check/db:*) |
|
||||
| Shared TS base | Missing | **Done** | `tsconfig.base.json` |
|
||||
| Scripts / ops helpers | Missing | **Done** | `scripts/` (env, docker-prod-*, admin, `security-static-check.mjs`, backup guides) |
|
||||
| Compose / Docker | Missing | **Done** | `docker-compose.dev.yml`, `docker-compose.production.yml`, `Dockerfile.dev` / `.production` / `.test`, `production/` mirror |
|
||||
| `.gitignore` | Missing | **Done** | Root `.gitignore` |
|
||||
| Static security script | Missing | **Done** | `scripts/security-static-check.mjs` + `npm run security:static` |
|
||||
| CI | No workflows | **Partial** | `.gitea/workflows/` (`test.yml`, `build-and-deploy.yml`) + `.gitlab-ci.yml`; **no** `.github/workflows` |
|
||||
| Notification outbox consumer | Missing | **Partial** | `processNotificationOutbox()` in `notificationService.ts`; cron every minute in `apps/api/src/index.ts` — still **inside the API process**; email/IN_APP delivery + DLQ; **no** `redis.publish` for realtime |
|
||||
| Prior hardening passes | — | **Documented** | `SECURITY_HARDENING_APPLIED_REPORT.md`, leftover report, `docs/SECURITY_HARDENING_*` (June 2026) — API-key hash-only, Socket.IO actor verify, cookie session work, etc. |
|
||||
|
||||
### 2.2 What remains strong
|
||||
|
||||
| Area | Evidence |
|
||||
|------|----------|
|
||||
| Product surfaces | `apps/homepage`, `dashboard`, `admin`, `carplace`, `api` |
|
||||
| Domain depth | Prisma: fleet, reservations, billing, payments, notifications, collections, admin |
|
||||
| API shape | Express modular monolith |
|
||||
| Auth / tenancy | JWT actors, HttpOnly cookies, admin 2FA, company middleware, subscription gates |
|
||||
| Payments foundation | Stripe / PayPal / AmanPay; webhook signature verify; `WebhookEvent` |
|
||||
| Upload validation | Magic bytes + MIME + size limits |
|
||||
| Deploy intent | Compose + Traefik configs, backup/restore scripts present |
|
||||
|
||||
### 2.3 What still blocks a production claim
|
||||
|
||||
| Priority | Gap | Repo evidence (current) |
|
||||
|----------|-----|-------------------------|
|
||||
| **P0** | App security Critical/High still open | Team API spreads `passwordHash` / reset tokens; plaintext invite tokens; open redirect; unrestricted payment return URLs on authenticated checkout; admin presenter scrub incomplete; container Docker-socket design still in tree — see **§3** |
|
||||
| **P0** | Ghost / dangerous container feature | `containerService.ts` + `apps/admin/.../containers/page.tsx` still present |
|
||||
| **P0** | Dependency SCA not freshly proven | Diligence (9 Aug): 21 prod vulns; must re-run on CI/clean runner |
|
||||
| **P0** | Phase 0 exit not fully evidenced | Envelope files exist, but clean-runner green CI + container removal + SCA gate not closed |
|
||||
| **P1** | Outbox not multi-replica safe | Processor runs via `node-cron` in every API process; no lease/lock; no Redis realtime publish |
|
||||
| **P1** | Process-local coordination | In-memory `express-rate-limit`; Carplace `idempotencyCache` `Map`; all crons in API `index.ts` |
|
||||
| **P1** | Local file storage | `FILE_STORAGE_ROOT` disk — no S3/MinIO adapter found |
|
||||
| **P1** | Ops blind spots | Only `GET /health`; no `/ready`; no `SIGTERM` graceful shutdown |
|
||||
| **P1** | Catalog drift | Homepage pricing vs shared plan/entitlement constants (unchanged risk) |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph done [Restored_envelope]
|
||||
Turbo[turbo_tsconfig]
|
||||
Compose[compose_Dockerfiles]
|
||||
Scripts[scripts_security_static]
|
||||
Gitignore[gitignore]
|
||||
end
|
||||
subgraph open [Still_open]
|
||||
Sec[App_security_Critical_High]
|
||||
Containers[containerService_UI]
|
||||
Shared[Redis_rate_limit_idempotency]
|
||||
Worker[Separate_worker_lease]
|
||||
Ready[ready_shutdown_object_store]
|
||||
end
|
||||
done --> open
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Security assessment (re-checked 12 Aug 2026 after update)
|
||||
|
||||
**Scope:** Static re-verification of findings from the earlier application review against current source.
|
||||
**Not in this pass:** Live pen-test, production secrets rotation, successful live `npm audit` (registry TLS may still block local runs).
|
||||
|
||||
### 3.1 Controls that remain solid
|
||||
|
||||
| Control | Evidence |
|
||||
|---------|----------|
|
||||
| Tenant scoping on core CRUD | Company routes use session `companyId` |
|
||||
| JWT from env + HS256 pinned | `security/tokens.ts` |
|
||||
| Session cookies HttpOnly / Secure / SameSite | `sessionCookies.ts` |
|
||||
| Hashed company API keys (legacy plaintext removed) | Hardening reports + schema |
|
||||
| Upload magic-byte validation | `http/upload` |
|
||||
| Payment webhook signatures | Stripe / PayPal / AmanPay paths |
|
||||
| Forwarded-header scrubbing by default | `sanitizeForwardedHeaders` unless `TRUSTED_FORWARD_HEADERS=true` |
|
||||
| Site payment redirect allowlist | `assertAllowedPaymentRedirect` in `site.service.ts` |
|
||||
|
||||
### 3.2 Findings status after project update
|
||||
|
||||
| Sev | ID | Status | Location | Finding |
|
||||
|-----|----|--------|----------|---------|
|
||||
| Critical | S1 | **FIXED (Phase 0)** | `teamService.ts` | Team list/invite use safe presenter; no hash/token leakage |
|
||||
| Critical | S2 | **FIXED (Phase 0)** | `containerService.ts` + admin containers page + ADR-001 | Feature disabled / fail-closed; UI shows out-of-scope |
|
||||
| High | S3 | **FIXED (Phase 0)** | `teamService.ts` invite | Invite token stored as SHA-256 hash |
|
||||
| High | S4 | **FIXED (Phase 0)** | Prisma `Employee` + auth repo | `@@unique([companyId, email])`; login fails closed on ambiguity |
|
||||
| High | S5 | **FIXED (Phase 0)** | `SignInForm.tsx` | Safe relative-path redirect only |
|
||||
| High | S6 | **FIXED (Phase 0)** | `paymentRedirects.ts` + payment/subscription services | Authenticated checkout allowlists return URLs |
|
||||
| High | S7 | **FIXED (Phase 0)** | `admin.schemas` / `admin.repo` | Slug regex + slugify on update |
|
||||
| High | S8 | **FIXED (Phase 0)** | `admin.presenter.ts` | Strips reset/verification secrets |
|
||||
| High | S9 | **FIXED (docs + default scrub)** | `forwardedHeaders.ts` + `docs/ops/proxy-trust.md` | Default scrub; TRUSTED_FORWARD_HEADERS documented |
|
||||
| Medium | S10 | **FIXED (Phase 0)** | employee/admin reset repos | Hash-only reset token lookup |
|
||||
| Medium | S11 | **FIXED (Phase 3)** | reservation/review presenters | `reviewToken` omitted from API JSON |
|
||||
| Medium | S12 | **FIXED** | `site.repo` / `site.service` | Public booking token unused-only on payment consume |
|
||||
| Medium | S13 | **FIXED** | `requireFreshAdmin2FA` | Max-age TTL (`ADMIN_FRESH_2FA_MAX_AGE_MS`, default 30m) |
|
||||
| Medium | S14 | **FIXED** | `.gitignore` | Present |
|
||||
| Medium | S15 | **FIXED** | `scripts/security-static-check.mjs` | Present; CI wired; local pass after env scrub |
|
||||
|
||||
### 3.3 Dependency / SCA status
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Diligence SCA (9 Aug 2026) | 21 production findings: 1 critical, 14 high, 5 moderate, 1 low |
|
||||
| Local re-audit | May fail on TLS to registry — **do not treat as clean** |
|
||||
| Required | CI/clean-runner `npm audit --omit=dev` (or OSV) with fail-on critical/high |
|
||||
|
||||
### 3.4 Security remediations (ordered)
|
||||
|
||||
**P0 — before any production traffic**
|
||||
|
||||
1. **S1:** Explicit safe select/presenter for team APIs — never return hashes/tokens
|
||||
2. **S3:** Hash invite tokens at rest; remove raw dual-match after migration (**S10**)
|
||||
3. **S8:** Expand admin presenter denylist
|
||||
4. **S2:** Remove/disable `containerService` + admin containers UI from GA
|
||||
5. **S14/S15:** Already fixed — keep in CI
|
||||
6. Re-run and clear dependency critical/high
|
||||
|
||||
**P1 — before multi-user GA**
|
||||
|
||||
7. **S4:** `@@unique([companyId, email])` + fail-closed login
|
||||
8. **S5:** Allowlist relative same-origin post-login redirects
|
||||
9. **S6:** Apply payment redirect allowlist to authenticated checkout/subscription
|
||||
10. **S7:** Slugify/validate admin slug updates
|
||||
11. **S9:** Document proxy trust; never enable trusted forwards without edge scrubbing
|
||||
12. **S11–S13:** Hide review tokens; single-use public access; align fresh 2FA on money mutations
|
||||
|
||||
### 3.5 Security exit criteria
|
||||
|
||||
- [ ] No API response includes password hashes, TOTP secrets, or raw reset/invite tokens
|
||||
- [ ] Invite/reset tokens hashed at rest; legacy raw match removed
|
||||
- [ ] Container/Docker-socket feature absent from production scope
|
||||
- [ ] Login redirect and payment return URLs allowlisted on all checkout paths
|
||||
- [ ] Employee email uniqueness (or fail-closed login) enforced
|
||||
- [ ] CI runs `security:static` + SCA; no unresolved critical/high outside dated exception
|
||||
- [ ] Independent pen-test after deploy envelope proven (Phase 3)
|
||||
|
||||
---
|
||||
|
||||
## 4. Guiding principles
|
||||
|
||||
1. **Evidence over claims** — Phase exit criteria must be demonstrable.
|
||||
2. **Close Critical/High security before scale work** — Envelope restore is largely done; app-sec P0 is now the front of the queue.
|
||||
3. **Shared state before more replicas** — Redis/DB/object storage before scaling API.
|
||||
4. **One worker plane** — Notifications and scheduled jobs must not run identically on every API replica.
|
||||
5. **Disable incomplete privileged features** — Especially Docker-socket container management.
|
||||
6. **One commercial source of truth** — Marketing, checkout, and enforcement share typed entitlements.
|
||||
7. **Extract services last** — Only after measurement.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 0 — Evidence recovery
|
||||
|
||||
**Goal:** Reproducible build/deploy envelope + security hygiene baseline.
|
||||
**Update:** Core files are **restored**. Remaining work is **prove + close security/container/SCA**.
|
||||
|
||||
### 5.1 Build orchestration — DONE
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `turbo.json` | Present |
|
||||
| `tsconfig.base.json` | Present |
|
||||
| `scripts/` (env, docker-prod, admin, security-static) | Present |
|
||||
|
||||
### 5.2 Runtime topology — DONE (files present)
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| `docker-compose.dev.yml` / `.production.yml` | Present |
|
||||
| `Dockerfile.dev` / `.production` / `.test` | Present |
|
||||
| Env examples | Present (`.env.example`, docker env samples) |
|
||||
| Backup/restore scripts | Present under `scripts/` |
|
||||
|
||||
**Still required as evidence:** recorded smoke that Compose boots API + Postgres + Redis and `/health` succeeds on a clean machine.
|
||||
|
||||
### 5.3 Continuous integration — PARTIAL
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Gitea workflows | Present (`.gitea/workflows/test.yml`, `build-and-deploy.yml`) |
|
||||
| GitLab CI | Present (`.gitlab-ci.yml`) |
|
||||
| GitHub Actions | Not present (optional if Gitea/GitLab is the system of record) |
|
||||
| Proven green run + SCA fail gate | **Not evidenced in this review** |
|
||||
|
||||
### 5.4 Ghost container control plane — NOT DONE
|
||||
|
||||
| Action | Status |
|
||||
|--------|--------|
|
||||
| Remove admin containers UI | **Still present** |
|
||||
| Quarantine/delete `containerService.ts` | **Still present** |
|
||||
| ADR: out of GA | Missing |
|
||||
|
||||
### 5.5 Dependency hygiene — NOT EVIDENCED
|
||||
|
||||
Must re-run SCA on CI and clear critical/high.
|
||||
|
||||
### Phase 0 exit criteria (updated checkboxes)
|
||||
|
||||
- [x] `turbo.json` + `tsconfig.base.json` + `scripts/` present in tree
|
||||
- [x] Compose + Dockerfiles present in tree
|
||||
- [x] `.gitignore` + `security:static` present
|
||||
- [x] Team API no longer returns password hashes / reset tokens; invite tokens hashed at rest (**S1/S3**)
|
||||
- [x] Admin presenter scrubs reset/verification secrets (**S8**)
|
||||
- [x] Container orchestration disabled + ADR (`docs/ADR-001-disable-per-tenant-containers.md`) (**S2**)
|
||||
- [x] Login redirect allowlisted; authenticated payment return URLs allowlisted; admin slug validated; employee `(companyId, email)` unique (**S4–S7**)
|
||||
- [x] Env example/dev templates scrubbed of real-looking secrets; `security:static` passes locally
|
||||
- [x] Gitea CI includes `security:static` + `npm audit --omit=dev --audit-level=high`
|
||||
- [ ] Fresh clone: `npm ci` → generate → lint → type-check → test → build with no local repair (needs recorded evidence on a clean runner)
|
||||
- [ ] CI green on default branch with SCA policy (push/run evidence)
|
||||
- [ ] Compose smoke: API + Postgres + Redis; `/health` ok (recorded)
|
||||
- [ ] No unresolved critical/high vulns outside dated exceptions (await CI audit result)
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 1 — Correctness and shared state
|
||||
|
||||
**Goal:** Two API replicas + one dedicated worker are correct under retries and failover.
|
||||
**Status (12 Aug 2026):** Core code landed. Still needs `npm ci`, migrate, and a two-replica Compose smoke for exit evidence.
|
||||
|
||||
### 6.1 Notification outbox — DONE in code
|
||||
|
||||
| Piece | Status |
|
||||
|-------|--------|
|
||||
| Process isolation | `apps/api/src/workers/index.ts` + Compose `api-worker`; API jobs only if `ENABLE_EMBEDDED_JOBS=true` |
|
||||
| Leasing | `lockedAt` / `lockedBy` / `attempts` / `availableAt` + claim via `updateMany` |
|
||||
| Realtime | `redis.publish('notifications:' + userId, …)` on IN_APP delivery |
|
||||
| Metrics | Phase 2 `/metrics` + outbox counters (scrape/alerts still open) |
|
||||
|
||||
### 6.2 Shared rate limiting — DONE in code
|
||||
|
||||
Redis store in `redisRateLimitStore.ts` (memory when `NODE_ENV=test` or `RATE_LIMIT_STORE=memory`).
|
||||
|
||||
### 6.3 Durable booking idempotency — DONE in code
|
||||
|
||||
`idempotencyStore.ts` (Redis; memory in test) used by Carplace `/reservations`.
|
||||
|
||||
### 6.4 Externalize scheduled work — DONE in code
|
||||
|
||||
Cron moved to `workers/jobs.ts` with Redis leader lock. API no longer starts cron by default.
|
||||
|
||||
### 6.5 Object storage — DONE in code (optional)
|
||||
|
||||
`FILE_STORAGE_DRIVER=local|s3` + `@aws-sdk/client-s3` + MinIO Compose profile `storage`. Default remains local disk.
|
||||
|
||||
### 6.6 Readiness and graceful shutdown — DONE in code
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| `GET /health` | Liveness |
|
||||
| `GET /ready` | DB + Redis + storage probes |
|
||||
| `SIGTERM` drain | API + worker close HTTP/Socket/Redis/Prisma |
|
||||
|
||||
### Phase 1 exit criteria
|
||||
|
||||
- [x] Worker entrypoint + outbox lease + Redis publish implemented
|
||||
- [x] Redis rate limits + durable Carplace idempotency implemented
|
||||
- [x] Cron externalized with leader lock; `/ready` + graceful shutdown implemented
|
||||
- [x] S3/MinIO adapter + Compose worker/MinIO services present
|
||||
- [ ] Two API replicas + one worker: recorded concurrency/retry smoke (needs runner)
|
||||
- [ ] Outbox no double-send under two API replicas (API without embedded jobs)
|
||||
- [ ] Files readable across replicas when `FILE_STORAGE_DRIVER=s3` (optional smoke)
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 2 — Operational control
|
||||
|
||||
**Code baselines landed (2026-08):** metrics/logs endpoint, plan catalog + tests, OpenAPI coverage gate, backup smoke check, privacy + billing/notification source-of-truth docs. Remaining work is **evidence** (drills, owners, alerts).
|
||||
|
||||
| Item | Status | Location |
|
||||
|------|--------|----------|
|
||||
| Structured JSON access logs + `/metrics` (latency, status, outbox) | **In code** | `apps/api/src/lib/opsMetrics.ts`, `app.ts`, worker |
|
||||
| Readiness `/ready` | **In code** (Phase 1) | DB / Redis / storage |
|
||||
| Plan/entitlement catalog + contract tests | **In code** | `packages/types/src/planCatalog.ts`, homepage pricing import |
|
||||
| OpenAPI completeness gate | **In code** | `npm run openapi:coverage` → `scripts/check-openapi-coverage.mjs` |
|
||||
| Backup artifact smoke check + RPO/RTO checklist | **In code / docs** | `scripts/backup-restore-smoke-check.sh`, `scripts/backup-restore-guide.md` |
|
||||
| Privacy data map | **Doc baseline** | `docs/PRIVACY_DATA_MAP.md` (owners / DSAR still open) |
|
||||
| Billing & notification SoT | **Doc baseline** | `docs/BILLING_NOTIFICATION_SOURCE_OF_TRUTH.md` |
|
||||
| Staging alert + restore drill evidence | **Open** | Ops exercise |
|
||||
| Provider reconciliation sample | **Open** | Finance / eng |
|
||||
|
||||
### Phase 2 exit criteria
|
||||
|
||||
- [ ] Staging incident detectable and recoverable via runbooks (metrics scraped + alert fired once)
|
||||
- [ ] Restore exercise meets RPO/RTO (dated drill using backup smoke check)
|
||||
- [ ] Provider reconciliation sample exists
|
||||
- [x] Plan catalog single-sourced with tests
|
||||
- [x] Privacy data map drafted (assign legal/ops owners before GA)
|
||||
- [x] OpenAPI coverage script in CI
|
||||
- [x] Billing/notification source-of-truth documented
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 3 — Scale and assurance
|
||||
|
||||
**Goal:** Prove the system holds under load/failure, tenant isolation, independent security testing, and operable release/secret drills. Keep the modular monolith until measurement says otherwise (ADR-002 defers RLS).
|
||||
|
||||
**Code / runbook baselines landed (2026-08):**
|
||||
|
||||
| Item | Status | Location |
|
||||
|------|--------|----------|
|
||||
| Cross-tenant negative suite | **In code** | `apps/api/src/tests/integration/cross-tenant-isolation.test.ts` |
|
||||
| S11 reviewToken scrubbed from API payloads | **FIXED** | reservation presenter + review service presenters |
|
||||
| Soak / load probe (+ optional k6) | **In code** | `scripts/load/soak-probe.mjs`, `booking-smoke.k6.js` → `npm run test:soak` |
|
||||
| Failure injection helper | **In code** | `scripts/chaos/failure-injection.sh` |
|
||||
| Canary / rollback runbook | **Doc** | `docs/ops/canary-rollback.md` |
|
||||
| Key rotation drill | **Doc** | `docs/ops/key-rotation-drill.md` |
|
||||
| Pen-test scope pack | **Doc** | `docs/security/pen-test-scope.md` |
|
||||
| Postgres RLS | **Deferred** | `docs/ADR-002-defer-postgres-rls.md` |
|
||||
|
||||
### Phase 3 exit criteria
|
||||
|
||||
- [ ] Soak + failure-injection drill completed on staging with dated metrics evidence
|
||||
- [x] Cross-tenant suite green in CI (`cross-tenant-isolation`) — **code landed**; CI run is evidence
|
||||
- [ ] Independent pen-test report in `security-reports/`; Critical/High closed or accepted
|
||||
- [ ] Canary promote **or** rollback drill recorded once
|
||||
- [ ] Key rotation drill recorded on staging
|
||||
- [x] App-level isolation suite started; RLS deferred per ADR-002
|
||||
- [x] S11 review tokens not returned in reservation/review JSON
|
||||
- [x] S12 single-use public payment token consume
|
||||
- [x] S13 fresh admin 2FA TTL
|
||||
|
||||
### Phase 4 (months 3–6) — extract only if measured
|
||||
|
||||
**Default:** stay on the modular monolith + `api-worker` (ADR-003). Phase 4 is **not** a microservices rewrite.
|
||||
|
||||
| Item | Status | Location |
|
||||
|------|--------|----------|
|
||||
| Extraction policy ADR | **Accepted** | `docs/ADR-003-defer-service-extraction.md` |
|
||||
| Gates G1–G7 + candidates | **Doc** | `docs/ops/phase4-extraction-gates.md` |
|
||||
| Measurement template | **Doc** | `docs/ops/phase4-measurement-template.md` |
|
||||
| Module seam map | **Doc** | `docs/ops/phase4-module-boundaries.md` |
|
||||
| Actual service split | **Blocked** until a candidate passes G1–G7 | — |
|
||||
|
||||
#### Phase 4 exit criteria (for *deciding*, not for “having microservices”)
|
||||
|
||||
- [x] Extraction gates and candidates documented
|
||||
- [x] In-monolith-first alternatives listed (scale workers, async media, thin webhooks)
|
||||
- [ ] At least one soak/profile attribution packaged with the measurement template (needs Phase 3 evidence)
|
||||
- [ ] Explicit decision recorded per candidate: **do not extract** (default) or approved extract ADR
|
||||
|
||||
Candidates if gates ever pass: payments/webhooks, notification worker as independent deployable, media processing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risk register → plan mapping
|
||||
|
||||
| ID | Risk | Status | Addressed by |
|
||||
|----|------|--------|--------------|
|
||||
| R1 | Unreproducible package | **Mostly mitigated** (files restored; prove CI smoke) | Phase 0 evidence |
|
||||
| R2 | Dependency vulnerabilities | **Open** (CI policy; clear on runner) | Phase 0.5 / CI SCA |
|
||||
| R3 | Privileged container feature | **Mitigated in code** (ADR-001) | Phase 0.4 + **S2** |
|
||||
| R4 | Outbox not dispatched | **Mitigated in code** (worker + leases) | Phase 1.1 |
|
||||
| R5 | Process-local rate limit / idempotency | **Mitigated in code** (Redis stores) | Phase 1.2–1.3 |
|
||||
| R6 | Embedded schedules | **Mitigated in code** (worker + lock) | Phase 1.4 |
|
||||
| R7 | Local file persistence | **Mitigated in code** (S3 driver) | Phase 1.5 |
|
||||
| R8 | Ops blind spots | **Partial** (metrics/logs in code; alerts/drills open) | Phase 1.6 + Phase 2 |
|
||||
| R9–R14 | Privacy, catalog, OpenAPI, legacy overlap, FE drift, flaky tests | **Partial / later** (catalog, OpenAPI gate, privacy map, SoT docs) | Phase 2–3 |
|
||||
| **S1–S8** | App security Critical/High | **Fixed in code** (S9 documented) | §3 P0/P1 |
|
||||
| **S10–S15** | Medium app security | **Fixed in code** (S11–S13 included) | Hardening + Phase 3 |
|
||||
| **S16–S21** | Medium/Low (undefined IDs) | **Track in pen-test** | Phase 3 evidence |
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested ownership and evidence log
|
||||
|
||||
| Field | Example |
|
||||
|-------|---------|
|
||||
| Phase | 0 / Security P0 |
|
||||
| Date | YYYY-MM-DD |
|
||||
| Commit / tag | `prod-ready-phase0` |
|
||||
| Commands | `npm ci && npm run type-check && npm run security:static && npm audit --omit=dev` |
|
||||
| Artifacts | CI URL, Compose smoke logs, audit report |
|
||||
| Exceptions | CVE-xxxx until DATE by OWNER |
|
||||
| Sign-off | Eng lead |
|
||||
|
||||
---
|
||||
|
||||
## 11. Explicit non-goals (until gates pass)
|
||||
|
||||
- Claiming production ready / HA / horizontally scaled before security P0 + Phase 1 exit evidence
|
||||
- Shipping per-tenant container orchestration
|
||||
- Guaranteeing SMS/push without provider paths + worker proof
|
||||
- Marketing KYC / license authenticity beyond date/expiry validation
|
||||
- Splitting the monolith for its own sake
|
||||
|
||||
---
|
||||
|
||||
## 12. Next backlog (when implementation resumes)
|
||||
|
||||
**Immediate (security + Phase 0 close)**
|
||||
|
||||
1. Fix team API secret leakage (**S1**); hash invite tokens (**S3**); scrub admin presenter (**S8**)
|
||||
2. Remove/disable `containerService` + admin containers UI (**S2**)
|
||||
3. Allowlist login redirect (**S5**) and authenticated payment return URLs (**S6**); slugify admin slugs (**S7**); employee email uniqueness (**S4**)
|
||||
4. Record clean-runner CI green + production SCA clear/critical policy
|
||||
5. Record Compose smoke evidence
|
||||
|
||||
**Then Phase 1**
|
||||
|
||||
6. Move outbox + cron to dedicated worker with leases; add Redis publish for realtime
|
||||
7. Redis rate-limit store; durable Carplace idempotency
|
||||
8. Object storage (MinIO/S3); `/ready` + graceful shutdown
|
||||
|
||||
**Then Phase 2–3**
|
||||
|
||||
9. Observability, restore drill, catalog convergence, privacy map (Phase 2 evidence still open)
|
||||
10. Soak/failure drills, cross-tenant CI green, pen-test report, canary + key-rotation evidence (Phase 3)
|
||||
|
||||
**Then Phase 4 (only if measured)**
|
||||
|
||||
11. Fill `docs/ops/phase4-measurement-template.md` from Phase 3 soak data; default decision remains **do not extract** (ADR-003)
|
||||
|
||||
---
|
||||
|
||||
## 13. Diligence checkpoint (definition of done)
|
||||
|
||||
1. Clean checkout builds/tests on documented Node/npm with no local repair
|
||||
2. Production SCA has no unresolved critical/high outside formal exception
|
||||
3. Two API replicas pass booking/payment/job/notification/file correctness with shared coordination
|
||||
4. Authoritative API contract + auth test matrix
|
||||
5. Restore exercise meets RPO/RTO
|
||||
6. Logs/metrics/alerts/runbooks demonstrated in a failure exercise
|
||||
7. Privacy controls cover classification, encryption plan, retention, privileged reads/exports, incident response
|
||||
8. Plan catalog / checkout / entitlements single-sourced
|
||||
9. Independent security testing closes critical/high or records explicit acceptance
|
||||
10. **§3 Critical/High application findings closed**
|
||||
|
||||
---
|
||||
|
||||
## 14. Thesis and kickoff
|
||||
|
||||
**This plan is how RentalDriveGo gets to production ready.** The monorepo envelope is largely restored; the remaining path is: close Critical/High app security → finish Phase 0 evidence → replica-safe Phase 1 → operate/recover Phase 2 → prove Phase 3 → pass §13.
|
||||
|
||||
**When implementation starts**, execute §12 in order. Do not skip security P0 for feature work. Do not claim production ready until §1 and §13 are evidenced.
|
||||
|
||||
---
|
||||
|
||||
*End of production-readiness plan. Code root: `D:\1\management`. Companion diligence: `RentalDriveGo_Technical_Architecture_and_Investor_Due_Diligence.docx`. Re-verified: 12 Aug 2026.*
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"message": "request to https://registry.npmjs.org/-/npm/v1/security/advisories/bulk failed, reason: unable to get local issuer certificate",
|
||||
"error": {
|
||||
"summary": "",
|
||||
"detail": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Canary deploy & rollback (Phase 3)
|
||||
|
||||
**Status:** Ops runbook — evidence required before claiming production ready
|
||||
**Date:** 2026-08-12
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a new API image to a small fraction of traffic, watch `/metrics` + `/ready`, and roll back within minutes if error rate or latency regresses.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Release image tagged and pulled (`scripts/docker-prod-deploy.sh` path)
|
||||
- Metrics scraped; alerts for 5xx and readiness exist (Phase 2)
|
||||
- Previous known-good image tag recorded
|
||||
|
||||
## Canary procedure
|
||||
|
||||
1. Record baseline: `p95` latency, 5xx rate, `notification_outbox_pending` for 15 minutes.
|
||||
2. Deploy new tag to **one** API replica (or Traefik weight 10% if configured).
|
||||
3. Watch 15–30 minutes under real or soak traffic (`npm run test:soak`).
|
||||
4. **Promote** only if error rate and p95 within agreed budget vs baseline.
|
||||
5. **Rollback** immediately if:
|
||||
- `/ready` flapping
|
||||
- 5xx rate > 2× baseline
|
||||
- Outbox pending unbounded growth
|
||||
- Payment/webhook failures spike
|
||||
|
||||
## Rollback
|
||||
|
||||
```bash
|
||||
# Set IMAGE_TAG to last known-good and redeploy
|
||||
export IMAGE_TAG=<previous-good>
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
```
|
||||
|
||||
Verify `/health`, `/ready`, and a booking smoke after rollback.
|
||||
|
||||
## Evidence to attach
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Date | |
|
||||
| Canary tag | |
|
||||
| Previous tag | |
|
||||
| Decision | promote / rollback |
|
||||
| Metrics summary | |
|
||||
| Operator | |
|
||||
@@ -0,0 +1,45 @@
|
||||
# Key rotation drill (Phase 3)
|
||||
|
||||
**Status:** Ops runbook
|
||||
**Date:** 2026-08-12
|
||||
|
||||
## Secrets in scope
|
||||
|
||||
| Secret | Used by | Rotation impact |
|
||||
|--------|---------|-----------------|
|
||||
| `JWT_SECRET` | API token signing | Invalidates existing employee/admin/renter sessions |
|
||||
| Company API keys | Partner/Carplace integrations | Per-company reissue via admin/API |
|
||||
| Payment provider webhooks | Stripe/PayPal/AmanPay | Update dashboard + env together |
|
||||
| DB / Redis passwords | Compose / managed services | Coordinated restart |
|
||||
| Object storage keys | S3/MinIO driver | Dual-key period preferred |
|
||||
|
||||
## JWT_SECRET drill (staging)
|
||||
|
||||
1. Announce maintenance window (sessions will drop).
|
||||
2. Generate a new high-entropy secret; store in secret manager **before** env change.
|
||||
3. Update staging `.env` / secret store; rolling-restart API + worker.
|
||||
4. Confirm:
|
||||
- Old bearer tokens return 401
|
||||
- Fresh login issues valid tokens
|
||||
- `/ready` stays healthy
|
||||
5. Record time-to-rotate and any customer-facing impact.
|
||||
|
||||
## Company API key drill
|
||||
|
||||
1. Create a replacement key for a test company.
|
||||
2. Switch the client to the new key.
|
||||
3. Revoke/disable the old key.
|
||||
4. Confirm old key fails; new key succeeds.
|
||||
|
||||
## Evidence
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Date | |
|
||||
| Environment | staging |
|
||||
| Secrets rotated | |
|
||||
| Duration | |
|
||||
| Issues | |
|
||||
| Operator | |
|
||||
|
||||
Do **not** rotate production secrets without dual-control and a tested rollback path for webhook signing secrets.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Phase 4 — Extraction gates & candidates
|
||||
|
||||
**Status:** Planning / measurement only (ADR-003)
|
||||
**Date:** 2026-08-12
|
||||
**Prerequisite:** Phase 3 exit evidence (soak, pen-test, canary, key rotation) should be underway before any extraction design review.
|
||||
|
||||
## Rule
|
||||
|
||||
Extract a bounded context into its **own deployable** only if measurement shows the monolith cannot meet SLOs or blast-radius requirements **and** ownership is staffed. Otherwise scale the existing `api` + `api-worker` topology.
|
||||
|
||||
## Candidates (from diligence / readiness plan)
|
||||
|
||||
| Candidate | Today | Extract when… | Prefer first |
|
||||
|-----------|--------|---------------|--------------|
|
||||
| **Notification worker** | Already a separate **process** (`apps/api` worker / Compose `api-worker`) sharing the API package | Worker CPU/memory or release cadence is blocked by API deploys; or need multi-language worker fleet with independent scaling | Scale `api-worker` replicas; keep shared package |
|
||||
| **Payments / webhooks** | Modules inside API (`payments`, `billing`, `subscriptions`, webhook routes) | Webhook lag or payment CPU dominates API p95; or PCI/provider isolation requires a smaller trust boundary | Dedicated webhook ingress + queue inside monolith; idempotent consumers |
|
||||
| **Media processing** | Upload + storage drivers in API; local/S3 | Image/PDF processing blocks request threads or storage I/O saturates API | Async job on `api-worker`; object storage (MinIO/S3) already preferred |
|
||||
|
||||
## Hard gates (all required per candidate)
|
||||
|
||||
Copy to an evidence note before any extract ADR:
|
||||
|
||||
| # | Gate | Evidence |
|
||||
|---|------|----------|
|
||||
| G1 | Phase 3 soak baseline exists | Dated `test:soak` / k6 + `/metrics` snapshot |
|
||||
| G2 | Bottleneck attributed | Profile or metrics: p95, queue lag, CPU by route/job — not anecdote |
|
||||
| G3 | Blast radius / compliance need | Written threat or compliance reason (e.g. webhook storm, media malware scan) |
|
||||
| G4 | Ownership | Named eng + on-call for the new service |
|
||||
| G5 | Contract | OpenAPI or event schema + consumer contract tests |
|
||||
| G6 | Dual-run | Shadow or dual-publish ≥ 1 release cycle with zero silent divergence |
|
||||
| G7 | Rollback | Documented cutback to monolith path in < 30 minutes |
|
||||
|
||||
If any gate fails → **do not extract**; file an in-monolith improvement instead.
|
||||
|
||||
## In-monolith improvements (do these before/instead of extract)
|
||||
|
||||
1. Keep payment/webhook handlers thin; push side effects through outbox/jobs.
|
||||
2. Run N `api-worker` replicas with DB leases (already Phase 1).
|
||||
3. Move heavy media work to worker jobs + S3; never resize on the request path.
|
||||
4. Publish domain events internally (same DB outbox) so a future service can subscribe without a big-bang rewrite.
|
||||
|
||||
## Explicit non-goals for Phase 4
|
||||
|
||||
- Rewriting the monorepo into many services
|
||||
- Separate databases per module “for purity”
|
||||
- Per-tenant container orchestration (still ADR-001)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Phase 4 — Extraction measurement template
|
||||
|
||||
Fill one copy per candidate (`notifications` | `payments-webhooks` | `media`). Store dated copies under `docs/ops/evidence/` (create as needed). Do not extract without G1–G7.
|
||||
|
||||
## Candidate
|
||||
|
||||
- Name:
|
||||
- Date:
|
||||
- Author:
|
||||
- Environment measured: staging / production
|
||||
|
||||
## G1 — Soak baseline
|
||||
|
||||
- Command / duration:
|
||||
- Error rate / p95:
|
||||
- Link or paste `/metrics` summary:
|
||||
|
||||
## G2 — Bottleneck
|
||||
|
||||
- Symptom (SLO miss):
|
||||
- Attribution (route, job, DB, Redis, storage):
|
||||
- Supporting graph or log sample:
|
||||
|
||||
## G3 — Blast radius / compliance
|
||||
|
||||
- Why a separate deployable (not just more workers):
|
||||
|
||||
## G4 — Ownership
|
||||
|
||||
- Eng owner:
|
||||
- On-call:
|
||||
- Budget for dual-run:
|
||||
|
||||
## G5 — Contract
|
||||
|
||||
- Sync API or async events:
|
||||
- Contract test location (planned):
|
||||
|
||||
## G6 — Dual-run plan
|
||||
|
||||
- Shadow period:
|
||||
- Diff/alert strategy:
|
||||
|
||||
## G7 — Rollback
|
||||
|
||||
- Cutback steps:
|
||||
- Drill date (must be practiced once):
|
||||
|
||||
## Decision
|
||||
|
||||
- [ ] **Do not extract** — pursue in-monolith item: _______________
|
||||
- [ ] **Extract** — write service ADR and implementation plan (separate change)
|
||||
|
||||
Sign-off: _______________ date: _______________
|
||||
@@ -0,0 +1,41 @@
|
||||
# Module boundaries for possible Phase 4 extracts
|
||||
|
||||
**Purpose:** Document current monolith seams so a future extract does not require archaeology. Not a license to split today (ADR-003).
|
||||
|
||||
## Notification / jobs
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Worker entry | `apps/api/src/workers/index.ts` |
|
||||
| Job loop | `apps/api/src/workers/jobs.ts` |
|
||||
| Outbox processing | `apps/api/src/services/notificationService` (and related) |
|
||||
| Compose | `api-worker` in `docker-compose*.yml` |
|
||||
|
||||
**Seam:** DB outbox + Redis pub for IN_APP. Extract later = move worker package + keep outbox schema shared or via events.
|
||||
|
||||
## Payments / webhooks
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Webhook router | `apps/api/src/modules/webhooks/` |
|
||||
| Payments | `apps/api/src/modules/payments/` |
|
||||
| Billing | `apps/api/src/modules/billing/` |
|
||||
| Subscriptions | `apps/api/src/modules/subscriptions/` |
|
||||
|
||||
**Seam:** Provider signature verify → idempotent intent/invoice update → outbox side effects. Extract later = webhook ingress service writing to the same billing tables or a command queue.
|
||||
|
||||
## Media
|
||||
|
||||
| Piece | Path |
|
||||
|-------|------|
|
||||
| Storage drivers | `apps/api/src/lib/storage` |
|
||||
| Upload HTTP | `apps/api/src/http/upload` (and module callers) |
|
||||
| Compose MinIO | `minio` profile in dev compose |
|
||||
|
||||
**Seam:** API accepts upload → stores object key → optional worker job for virus scan / derivatives. Extract later = media service owning bucket + callback to API.
|
||||
|
||||
## Shared that must not fork casually
|
||||
|
||||
- Prisma schema / migrations (`packages/database`)
|
||||
- Auth tokens / tenant `companyId` middleware
|
||||
- Plan catalog (`packages/types` planCatalog)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Proxy trust & forwarded headers (S9)
|
||||
|
||||
**Default:** `TRUSTED_FORWARD_HEADERS` is unset/false. The API **deletes** client-supplied forwarding headers (`x-forwarded-for`, `x-real-ip`, etc.) so rate limits and logs cannot be spoofed.
|
||||
|
||||
## When to set `TRUSTED_FORWARD_HEADERS=true`
|
||||
|
||||
Only when:
|
||||
|
||||
1. Traefik / nginx / cloud LB is the **only** ingress to the API.
|
||||
2. That edge **overwrites** (not appends blindly from the client) trusted client IP / proto headers.
|
||||
3. The API `trust proxy` hop count matches the real proxy chain.
|
||||
|
||||
If any of those are false, leave the flag off.
|
||||
|
||||
## Operator checklist
|
||||
|
||||
- [ ] Edge strips spoofed `X-Forwarded-*` from the public internet
|
||||
- [ ] Documented hop count for Express `trust proxy`
|
||||
- [ ] Rate-limit keys still meaningful after deploy
|
||||
- [ ] Change reviewed in incident/runbook (misconfiguration → IP allowlist / rate-limit bypass)
|
||||
|
||||
Related: `apps/api/src/middleware/forwardedHeaders.ts`
|
||||
@@ -35,6 +35,7 @@ There is no separate frontend app for a white-label company public site in this
|
||||
|
||||
- Company-scoped vehicles, customers, reservations, offers, payments, complaints, and notifications
|
||||
- API tenant isolation through employee auth plus `companyId` scoping
|
||||
- Cross-tenant negative regression suite (`cross-tenant-isolation` integration tests)
|
||||
- Company profile, brand, contract settings, insurance policies, pricing rules, and accounting settings
|
||||
- Public API key generation/regeneration for company integrations
|
||||
|
||||
@@ -76,10 +77,12 @@ There is no separate frontend app for a white-label company public site in this
|
||||
|
||||
- SaaS trial and subscription lifecycle
|
||||
- Subscription status handling including `TRIALING`, `ACTIVE`, `PAYMENT_PENDING`, `PAST_DUE`, `SUSPENDED`, `CANCELLED`, `EXPIRED`, `PAUSED`, `UNPAID`
|
||||
- Plan pricing loaded from DB or fallback config
|
||||
- Plan pricing and entitlements from a single catalog (`packages/types` `planCatalog`) with DB overrides where configured
|
||||
- Homepage marketing prices derived from the same catalog
|
||||
- AmanPay and PayPal provider support for SaaS subscription checkout
|
||||
- Subscription invoice history
|
||||
- Platform billing accounts, billing invoices, billing events, refunds, credit notes, and tax records
|
||||
- Canonical billing models preferred over legacy subscription-invoice-only writes (see `docs/BILLING_NOTIFICATION_SOURCE_OF_TRUTH.md`)
|
||||
|
||||
### Rental payments
|
||||
|
||||
@@ -97,6 +100,16 @@ There is no separate frontend app for a white-label company public site in this
|
||||
- In-app notifications
|
||||
- Notification templates and per-channel preferences
|
||||
- Notification history for company users
|
||||
- Durable notification outbox with leased dispatch on `api-worker` (Redis publish for IN_APP)
|
||||
- Canonical event/recipient/delivery models preferred over legacy flat notifications
|
||||
|
||||
### Ops and production-readiness baselines
|
||||
|
||||
- `GET /ready` and `GET /metrics` on the API
|
||||
- Shared Redis rate limiting and Carplace idempotency
|
||||
- Optional S3/MinIO file storage driver
|
||||
- OpenAPI coverage CI gate
|
||||
- Design summary: `docs/design/`
|
||||
|
||||
### Reservation-adjacent advanced operations
|
||||
|
||||
@@ -112,6 +125,7 @@ There is no separate frontend app for a white-label company public site in this
|
||||
### Admin app
|
||||
|
||||
- Admin login and password reset
|
||||
- Admin 2FA enrollment; fresh 2FA required for money / high-privilege mutations (TTL)
|
||||
- Admin dashboard
|
||||
- Company management
|
||||
- Renter management
|
||||
@@ -121,6 +135,7 @@ There is no separate frontend app for a white-label company public site in this
|
||||
- Pricing configuration and promotions
|
||||
- Notification review
|
||||
- Carplace/site config management
|
||||
- Per-tenant Docker/container orchestration UI is **out of scope** (ADR-001)
|
||||
|
||||
## Present But Not Active Product Features
|
||||
|
||||
@@ -135,6 +150,8 @@ These exist partially in code or schema, but they are not active end-user produc
|
||||
- Admin impersonation through Clerk sessions
|
||||
- Multi-currency SaaS checkout beyond `MAD`
|
||||
- Separate white-label company-site frontend app
|
||||
- Per-tenant Docker container orchestration (explicitly disabled — ADR-001)
|
||||
- Microservice extraction of payments/webhooks/media (deferred — ADR-003)
|
||||
|
||||
## Explicitly Out Of Scope For Current Docs
|
||||
|
||||
|
||||
@@ -13,19 +13,24 @@ Source of truth for the runtime wiring:
|
||||
|
||||
The API is an Express application mounted under `/api/v1` with a few non-versioned utility endpoints:
|
||||
|
||||
- `GET /health` returns process health.
|
||||
- `GET /health` returns process liveness.
|
||||
- `GET /ready` returns readiness (PostgreSQL, Redis, storage). Returns `503` when a dependency fails.
|
||||
- `GET /metrics` returns Prometheus-style ops metrics (request counts/latency, outbox gauges).
|
||||
- `GET /docs` serves Swagger UI.
|
||||
- `GET /api/v1/openapi.json` returns the generated OpenAPI document.
|
||||
- `/storage/*` serves uploaded assets such as logos, hero images, vehicle photos, and reservation/customer documents.
|
||||
|
||||
A dedicated **`api-worker`** process runs notification outbox dispatch and scheduled jobs (Phase 1). The API process must not embed those jobs when multiple replicas are used (`ENABLE_EMBEDDED_JOBS` stays false). See `docs/design/RUNTIME_AND_OPS_SURFACE.md`.
|
||||
|
||||
The app boot sequence in `apps/api/src/app.ts` is intentionally ordered:
|
||||
|
||||
1. CORS is applied first.
|
||||
2. storage guards are applied before static serving so private customer license files are never anonymously retrievable.
|
||||
3. Swagger UI is mounted before Helmet so the UI assets are not blocked by CSP.
|
||||
4. webhook routes that require raw payload handling are mounted before `express.json()`.
|
||||
5. Helmet, request logging, JSON parsing, and the module routers are mounted.
|
||||
5. Helmet, request logging (structured JSON), metrics middleware, JSON parsing, and the module routers are mounted.
|
||||
6. the centralized error middleware converts validation, Prisma, and application errors into consistent JSON responses.
|
||||
7. Spoofable forwarded headers are scrubbed unless `TRUSTED_FORWARD_HEADERS=true` (see `docs/ops/proxy-trust.md`).
|
||||
|
||||
## Request Lifecycle
|
||||
|
||||
@@ -382,6 +387,8 @@ Successful route handlers usually return:
|
||||
Common deviations:
|
||||
|
||||
- `/health`
|
||||
- `/ready`
|
||||
- `/metrics`
|
||||
- `/api/v1/docs`
|
||||
- webhook receipt payloads
|
||||
- file downloads such as admin invoice PDFs
|
||||
@@ -393,4 +400,6 @@ There are two documentation layers in the codebase:
|
||||
1. runtime API docs from Swagger/OpenAPI at `/docs` and `/api/v1/openapi.json`
|
||||
2. this markdown document, which explains design decisions, route grouping, and request flow
|
||||
|
||||
The OpenAPI file is useful for request/response contracts, but it is not yet a perfect mirror of every newer route. This document should be updated together with `app.ts`, route files, and the OpenAPI generator whenever route groups change.
|
||||
Production-readiness design updates (Phases 0–4) are summarized under `docs/design/`.
|
||||
|
||||
The OpenAPI file is useful for request/response contracts. CI enforces a minimum coverage gate via `npm run openapi:coverage`. This document should be updated together with `app.ts`, route files, and the OpenAPI generator whenever route groups change.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Penetration test scope (Phase 3)
|
||||
|
||||
**Status:** Scope pack for an independent tester
|
||||
**Date:** 2026-08-12
|
||||
**In-repo evidence folder:** `security-reports/` (attach PDF/HTML outputs here; do not commit secrets)
|
||||
|
||||
## In scope
|
||||
|
||||
- Public marketing site, Carplace booking, company dashboard, admin console
|
||||
- API ` /api/v1/* `, `/health`, `/ready`, `/metrics` (metrics should not expose PII)
|
||||
- Auth: employee, renter, admin (incl. 2FA), company API keys
|
||||
- Multi-tenant isolation (IDOR across companies)
|
||||
- Payments / webhooks (signature bypass attempts)
|
||||
- File upload / private media paths
|
||||
- Session cookies, CSRF on cookie mutations, redirect/open-redirect
|
||||
|
||||
## Out of scope (unless separately contracted)
|
||||
|
||||
- Physical / social engineering
|
||||
- Third-party Clerk/payment provider infrastructure itself
|
||||
- DoS that risks shared staging cost blowups (use agreed soak limits)
|
||||
|
||||
## Priority assertions to verify
|
||||
|
||||
1. No password hashes, TOTP secrets, raw invite/reset/review tokens in API JSON (**S11 closed in code**)
|
||||
2. Cross-tenant resource access returns 404 without leakage
|
||||
3. Webhook endpoints reject unsigned/forged payloads
|
||||
4. Admin money-moving actions require fresh 2FA where designed
|
||||
5. Container/Docker orchestration remains disabled (ADR-001)
|
||||
|
||||
## Exit for Phase 3
|
||||
|
||||
- [ ] Independent report attached under `security-reports/`
|
||||
- [ ] All Critical/High findings fixed or formally accepted with owner + date
|
||||
- [ ] Retest of fixed Critical/High completed
|
||||
|
||||
## Suggested tooling (tester choice)
|
||||
|
||||
OWASP ZAP / Burp, authenticated scripted checks mirroring `cross-tenant-isolation.test.ts`, dependency SCA already in CI.
|
||||
Generated
+399
-2
@@ -59,6 +59,7 @@
|
||||
"name": "@rentaldrivego/api",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@react-pdf/renderer": "^3.4.3",
|
||||
"@rentaldrivego/database": "*",
|
||||
"@rentaldrivego/types": "*",
|
||||
@@ -182,6 +183,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
|
||||
"@rentaldrivego/types": "*",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"next": "^16.2.9",
|
||||
"node-cron": "4.5.0",
|
||||
@@ -537,6 +539,314 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.27",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz",
|
||||
"integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1109.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1109.0.tgz",
|
||||
"integrity": "sha512-iPWzBeGkAe5H5+dBBGCOdIT4uMhpu12OK+nFnDzjvOChVnHIws4LeoG7Yl0kJHSRWZnNEkVcF4vQYTJny0e5xA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1000.27",
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.79",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.73",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.44",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/fetch-http-handler": "^5.6.13",
|
||||
"@smithy/node-http-handler": "^4.9.13",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.977.7",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz",
|
||||
"integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@aws-sdk/xml-builder": "^3.972.38",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/signature-v4": "^5.6.12",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.68",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz",
|
||||
"integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.70",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz",
|
||||
"integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/fetch-http-handler": "^5.6.13",
|
||||
"@smithy/node-http-handler": "^4.9.13",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.13",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz",
|
||||
"integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.68",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.75",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.68",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.12",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.74",
|
||||
"@aws-sdk/nested-clients": "^3.997.42",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.75",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz",
|
||||
"integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/nested-clients": "^3.997.42",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.79",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz",
|
||||
"integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.68",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.13",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.68",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.12",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.74",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.68",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz",
|
||||
"integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.12",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz",
|
||||
"integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/nested-clients": "^3.997.42",
|
||||
"@aws-sdk/token-providers": "3.1108.0",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.74",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz",
|
||||
"integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/nested-clients": "^3.997.42",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.73",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz",
|
||||
"integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.44",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.42",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz",
|
||||
"integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.44",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/fetch-http-handler": "^5.6.13",
|
||||
"@smithy/node-http-handler": "^4.9.13",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.44",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz",
|
||||
"integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/signature-v4": "^5.6.12",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1108.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz",
|
||||
"integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.7",
|
||||
"@aws-sdk/nested-clients": "^3.997.42",
|
||||
"@aws-sdk/types": "^3.974.3",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.974.3",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz",
|
||||
"integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.38",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz",
|
||||
"integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.16.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.12.1",
|
||||
"dev": true,
|
||||
@@ -2256,6 +2566,87 @@
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz",
|
||||
"integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.17.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz",
|
||||
"integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.32.0",
|
||||
"@smithy/types": "^4.17.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz",
|
||||
"integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.32.0",
|
||||
"@smithy/types": "^4.17.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz",
|
||||
"integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.32.0",
|
||||
"@smithy/types": "^4.17.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz",
|
||||
"integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.32.0",
|
||||
"@smithy/types": "^4.17.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz",
|
||||
"integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"license": "MIT"
|
||||
@@ -3629,6 +4020,12 @@
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"dev": true,
|
||||
@@ -8449,7 +8846,6 @@
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11772,7 +12168,8 @@
|
||||
"name": "@rentaldrivego/types",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
|
||||
+6
-1
@@ -67,7 +67,12 @@
|
||||
"test:unit": "npm run test:api && npm run test:frontends",
|
||||
"test:integration": "npm run test:api:integration && npm run test:homepage:integration",
|
||||
"security:static": "node scripts/security-static-check.mjs",
|
||||
"security:scan": "npm run security:static && npm audit --production"
|
||||
"security:scan": "npm run security:static && npm audit --production",
|
||||
"openapi:coverage": "node scripts/check-openapi-coverage.mjs",
|
||||
"test:types": "npm run test --workspace @rentaldrivego/types",
|
||||
"backup:smoke-check": "bash scripts/backup-restore-smoke-check.sh",
|
||||
"test:soak": "node scripts/load/soak-probe.mjs",
|
||||
"chaos:checklist": "bash scripts/chaos/failure-injection.sh checklist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- Phase 0: unique employee email per company + normalize existing emails.
|
||||
UPDATE "employees" SET "email" = lower("email") WHERE "email" <> lower("email");
|
||||
|
||||
-- If duplicate (companyId, email) rows exist, this statement fails intentionally so operators resolve data before enforcing uniqueness.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "employees_companyId_email_key" ON "employees"("companyId", "email");
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- Phase 1: outbox leasing for multi-worker safe dispatch
|
||||
ALTER TABLE "notification_outbox" ADD COLUMN IF NOT EXISTS "locked_at" TIMESTAMP(3);
|
||||
ALTER TABLE "notification_outbox" ADD COLUMN IF NOT EXISTS "locked_by" TEXT;
|
||||
ALTER TABLE "notification_outbox" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "notification_outbox" ADD COLUMN IF NOT EXISTS "available_at" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "notification_outbox_status_available_at_idx"
|
||||
ON "notification_outbox"("status", "available_at");
|
||||
@@ -1513,7 +1513,9 @@ model Employee {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([companyId, email])
|
||||
@@index([companyId])
|
||||
@@index([email])
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
@@ -2085,10 +2087,15 @@ model NotificationOutbox {
|
||||
payload Json
|
||||
publishedAt DateTime?
|
||||
failureReason String?
|
||||
lockedAt DateTime? @map("locked_at")
|
||||
lockedBy String? @map("locked_by")
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime? @map("available_at")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([status, availableAt])
|
||||
@@map("notification_outbox")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
"scripts": {
|
||||
"dev": "tsc --watch --preserveWatchOutput --watchFile dynamicprioritypolling --watchDirectory dynamicprioritypolling",
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-58
@@ -19,69 +19,41 @@ export interface ApiError {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Plan prices in smallest currency unit (MAD, in centimes)
|
||||
export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>> = {
|
||||
STARTER: {
|
||||
MONTHLY: { MAD: 14900 },
|
||||
ANNUAL: { MAD: 143040 },
|
||||
},
|
||||
GROWTH: {
|
||||
MONTHLY: { MAD: 29900 },
|
||||
ANNUAL: { MAD: 287040 },
|
||||
},
|
||||
PRO: {
|
||||
MONTHLY: { MAD: 39900 },
|
||||
ANNUAL: { MAD: 383040 },
|
||||
},
|
||||
ENTERPRISE: {
|
||||
MONTHLY: { MAD: 59900 },
|
||||
ANNUAL: { MAD: 575040 },
|
||||
},
|
||||
}
|
||||
export {
|
||||
PLAN_PRICES,
|
||||
PLAN_ENTITLEMENTS,
|
||||
SUBSCRIPTION_CAPABILITIES,
|
||||
ANNUAL_DISCOUNT_PERCENT,
|
||||
PUBLIC_PRICING_PLAN_MAP,
|
||||
getVehicleLimit,
|
||||
planHasCapability,
|
||||
getPublicMonthlyMajorUnits,
|
||||
SUBSCRIPTION_PLAN_IDS,
|
||||
BILLING_PERIODS,
|
||||
} from './planCatalog'
|
||||
export type {
|
||||
SubscriptionPlanId,
|
||||
SubscriptionCapability,
|
||||
CatalogBillingPeriod,
|
||||
PublicPricingPlanId,
|
||||
PlanEntitlements,
|
||||
} from './planCatalog'
|
||||
|
||||
import { PLAN_ENTITLEMENTS, type SubscriptionCapability } from './planCatalog'
|
||||
|
||||
/** Display labels derived from the entitlement catalog (not used for enforcement). */
|
||||
export const PLAN_FEATURES: Record<string, string[]> = {
|
||||
STARTER: [
|
||||
'Up to 25 vehicles',
|
||||
'1 user account',
|
||||
'Basic analytics',
|
||||
'Carplace listing',
|
||||
'Notification management',
|
||||
],
|
||||
GROWTH: [
|
||||
'Up to 75 vehicles',
|
||||
'5 user accounts',
|
||||
'Full analytics',
|
||||
'Priority Carplace placement',
|
||||
'Custom branding',
|
||||
],
|
||||
PRO: [
|
||||
'Up to 150 vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
ENTERPRISE: [
|
||||
'150+ vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
STARTER: PLAN_ENTITLEMENTS.STARTER.featureLabels,
|
||||
GROWTH: PLAN_ENTITLEMENTS.GROWTH.featureLabels,
|
||||
PRO: PLAN_ENTITLEMENTS.PRO.featureLabels,
|
||||
ENTERPRISE: PLAN_ENTITLEMENTS.ENTERPRISE.featureLabels,
|
||||
}
|
||||
|
||||
export const SUBSCRIPTION_CAPABILITIES = {
|
||||
NOTIFICATION_MANAGEMENT: 'NOTIFICATION_MANAGEMENT',
|
||||
} as const
|
||||
|
||||
export type SubscriptionCapability =
|
||||
(typeof SUBSCRIPTION_CAPABILITIES)[keyof typeof SUBSCRIPTION_CAPABILITIES]
|
||||
|
||||
export const PLAN_CAPABILITIES: Record<string, SubscriptionCapability[]> = {
|
||||
STARTER: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
GROWTH: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
PRO: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
ENTERPRISE: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
STARTER: PLAN_ENTITLEMENTS.STARTER.capabilities,
|
||||
GROWTH: PLAN_ENTITLEMENTS.GROWTH.capabilities,
|
||||
PRO: PLAN_ENTITLEMENTS.PRO.capabilities,
|
||||
ENTERPRISE: PLAN_ENTITLEMENTS.ENTERPRISE.capabilities,
|
||||
}
|
||||
|
||||
export type Locale = 'en' | 'fr' | 'ar'
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './damage'
|
||||
export * from './fuel'
|
||||
export * from './api'
|
||||
export * from './carplace-homepage'
|
||||
// planCatalog is re-exported via api.ts (single public surface)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ANNUAL_DISCOUNT_PERCENT,
|
||||
PLAN_ENTITLEMENTS,
|
||||
PLAN_PRICES,
|
||||
PUBLIC_PRICING_PLAN_MAP,
|
||||
SUBSCRIPTION_PLAN_IDS,
|
||||
getPublicMonthlyMajorUnits,
|
||||
getVehicleLimit,
|
||||
} from './planCatalog'
|
||||
import { PLAN_FEATURES as apiPlanFeatures, PLAN_PRICES as apiPlanPrices } from './api'
|
||||
|
||||
describe('planCatalog source of truth', () => {
|
||||
it('defines all subscription plans with prices and entitlements', () => {
|
||||
for (const plan of SUBSCRIPTION_PLAN_IDS) {
|
||||
expect(PLAN_PRICES[plan].MONTHLY.MAD).toBeGreaterThan(0)
|
||||
expect(PLAN_PRICES[plan].ANNUAL.MAD).toBeGreaterThan(0)
|
||||
expect(PLAN_ENTITLEMENTS[plan].featureLabels.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps api.ts re-exports aligned with the catalog', () => {
|
||||
expect(apiPlanPrices).toEqual(PLAN_PRICES)
|
||||
expect(apiPlanFeatures.STARTER).toEqual(PLAN_ENTITLEMENTS.STARTER.featureLabels)
|
||||
})
|
||||
|
||||
it('maps public marketing plans to subscription plans without price drift', () => {
|
||||
expect(PUBLIC_PRICING_PLAN_MAP.launch).toBe('STARTER')
|
||||
expect(PUBLIC_PRICING_PLAN_MAP.growth).toBe('GROWTH')
|
||||
expect(getPublicMonthlyMajorUnits('launch')).toBe(PLAN_PRICES.STARTER.MONTHLY.MAD / 100)
|
||||
expect(getPublicMonthlyMajorUnits('growth')).toBe(PLAN_PRICES.GROWTH.MONTHLY.MAD / 100)
|
||||
expect(getPublicMonthlyMajorUnits('enterprise')).toBeNull()
|
||||
expect(ANNUAL_DISCOUNT_PERCENT).toBe(20)
|
||||
})
|
||||
|
||||
it('exposes typed vehicle limits for enforcement', () => {
|
||||
expect(getVehicleLimit('STARTER')).toBe(25)
|
||||
expect(getVehicleLimit('GROWTH')).toBe(75)
|
||||
expect(getVehicleLimit('PRO')).toBe(150)
|
||||
expect(getVehicleLimit('ENTERPRISE')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Single commercial plan / entitlement catalog.
|
||||
* Marketing, checkout, API enforcement, and invoices must consume this module
|
||||
* (or DB PricingConfig overrides that stay aligned with these plan IDs).
|
||||
*/
|
||||
|
||||
export const SUBSCRIPTION_PLAN_IDS = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'] as const
|
||||
export type SubscriptionPlanId = (typeof SUBSCRIPTION_PLAN_IDS)[number]
|
||||
|
||||
export const BILLING_PERIODS = ['MONTHLY', 'ANNUAL'] as const
|
||||
export type CatalogBillingPeriod = (typeof BILLING_PERIODS)[number]
|
||||
|
||||
export const SUBSCRIPTION_CAPABILITIES = {
|
||||
NOTIFICATION_MANAGEMENT: 'NOTIFICATION_MANAGEMENT',
|
||||
} as const
|
||||
|
||||
export type SubscriptionCapability =
|
||||
(typeof SUBSCRIPTION_CAPABILITIES)[keyof typeof SUBSCRIPTION_CAPABILITIES]
|
||||
|
||||
export type PlanEntitlements = {
|
||||
/** null = unlimited */
|
||||
maxVehicles: number | null
|
||||
maxUsers: number | null
|
||||
capabilities: SubscriptionCapability[]
|
||||
/** Marketing display labels (not used for enforcement) */
|
||||
featureLabels: string[]
|
||||
}
|
||||
|
||||
/** Prices in smallest currency unit (MAD centimes). */
|
||||
export type PlanPriceTable = Record<
|
||||
SubscriptionPlanId,
|
||||
Record<CatalogBillingPeriod, Record<'MAD', number>>
|
||||
>
|
||||
|
||||
export const PLAN_ENTITLEMENTS: Record<SubscriptionPlanId, PlanEntitlements> = {
|
||||
STARTER: {
|
||||
maxVehicles: 25,
|
||||
maxUsers: 1,
|
||||
capabilities: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
featureLabels: [
|
||||
'Up to 25 vehicles',
|
||||
'1 user account',
|
||||
'Basic analytics',
|
||||
'Carplace listing',
|
||||
'Notification management',
|
||||
],
|
||||
},
|
||||
GROWTH: {
|
||||
maxVehicles: 75,
|
||||
maxUsers: 5,
|
||||
capabilities: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
featureLabels: [
|
||||
'Up to 75 vehicles',
|
||||
'5 user accounts',
|
||||
'Full analytics',
|
||||
'Priority Carplace placement',
|
||||
'Custom branding',
|
||||
],
|
||||
},
|
||||
PRO: {
|
||||
maxVehicles: 150,
|
||||
maxUsers: null,
|
||||
capabilities: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
featureLabels: [
|
||||
'Up to 150 vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
},
|
||||
ENTERPRISE: {
|
||||
maxVehicles: null,
|
||||
maxUsers: null,
|
||||
capabilities: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
|
||||
featureLabels: [
|
||||
'150+ vehicles',
|
||||
'Unlimited user accounts',
|
||||
'Advanced reports',
|
||||
'API access',
|
||||
'Dedicated support',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const PLAN_PRICES: PlanPriceTable = {
|
||||
STARTER: {
|
||||
MONTHLY: { MAD: 14900 },
|
||||
ANNUAL: { MAD: 143040 },
|
||||
},
|
||||
GROWTH: {
|
||||
MONTHLY: { MAD: 29900 },
|
||||
ANNUAL: { MAD: 287040 },
|
||||
},
|
||||
PRO: {
|
||||
MONTHLY: { MAD: 39900 },
|
||||
ANNUAL: { MAD: 383040 },
|
||||
},
|
||||
ENTERPRISE: {
|
||||
MONTHLY: { MAD: 59900 },
|
||||
ANNUAL: { MAD: 575040 },
|
||||
},
|
||||
}
|
||||
|
||||
/** Public homepage marketing IDs mapped to subscription plan IDs. */
|
||||
export const PUBLIC_PRICING_PLAN_MAP = {
|
||||
launch: 'STARTER',
|
||||
growth: 'GROWTH',
|
||||
/** Public "enterprise" is custom quote; checkout still uses ENTERPRISE entitlements when sold. */
|
||||
enterprise: 'ENTERPRISE',
|
||||
} as const
|
||||
|
||||
export type PublicPricingPlanId = keyof typeof PUBLIC_PRICING_PLAN_MAP
|
||||
|
||||
export const ANNUAL_DISCOUNT_PERCENT = 20
|
||||
|
||||
export function getVehicleLimit(plan: string): number | null {
|
||||
const entitlements = PLAN_ENTITLEMENTS[plan as SubscriptionPlanId]
|
||||
return entitlements ? entitlements.maxVehicles : PLAN_ENTITLEMENTS.STARTER.maxVehicles
|
||||
}
|
||||
|
||||
export function planHasCapability(plan: string, capability: SubscriptionCapability): boolean {
|
||||
const entitlements = PLAN_ENTITLEMENTS[plan as SubscriptionPlanId]
|
||||
return Boolean(entitlements?.capabilities.includes(capability))
|
||||
}
|
||||
|
||||
/** Major-unit monthly MAD prices for public marketing (null = custom). */
|
||||
export function getPublicMonthlyMajorUnits(planId: PublicPricingPlanId): number | null {
|
||||
if (planId === 'enterprise') return null
|
||||
const subscriptionPlan = PUBLIC_PRICING_PLAN_MAP[planId]
|
||||
return PLAN_PRICES[subscriptionPlan].MONTHLY.MAD / 100
|
||||
}
|
||||
@@ -7,5 +7,6 @@
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user