fix cicd issues
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 1m16s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m10s
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 1m16s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m10s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
if (request.headers.has('x-middleware-subrequest')) {
|
if (request.headers.has('x-middleware-subrequest')) {
|
||||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
|||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
import { verifyAnyActorToken } from '../security/tokens'
|
import { verifyAnyActorToken } from '../security/tokens'
|
||||||
import { getSessionCookieName } from '../security/sessionCookies'
|
import { getSessionCookieName } from '../security/sessionCookies'
|
||||||
import { sharedRateLimitStore } from './redisRateLimitStore'
|
import { RedisRateLimitStore } from './redisRateLimitStore'
|
||||||
|
|
||||||
const SESSION_COOKIE_NAMES = [
|
const SESSION_COOKIE_NAMES = [
|
||||||
getSessionCookieName('admin'),
|
getSessionCookieName('admin'),
|
||||||
@@ -53,14 +53,15 @@ function getAuthenticatedActorKey(req: Request): string | null {
|
|||||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||||
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
|
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
|
||||||
|
|
||||||
function withStore(options: Parameters<typeof rateLimit>[0]) {
|
/** express-rate-limit forbids reusing one Store across limiters — unique prefix per limiter. */
|
||||||
|
function withStore(prefix: string, options: Parameters<typeof rateLimit>[0]) {
|
||||||
return rateLimit({
|
return rateLimit({
|
||||||
...options,
|
...options,
|
||||||
store: sharedRateLimitStore,
|
store: new RedisRateLimitStore(prefix),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authLimiter = withStore({
|
export const authLimiter = withStore('rl:auth:', {
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 20,
|
max: 20,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
@@ -71,7 +72,7 @@ export const authLimiter = withStore({
|
|||||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const apiLimiter = withStore({
|
export const apiLimiter = withStore('rl:api:', {
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 300,
|
max: 300,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
@@ -87,7 +88,7 @@ export const apiLimiter = withStore({
|
|||||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const publicLimiter = withStore({
|
export const publicLimiter = withStore('rl:public:', {
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 60,
|
max: 60,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
@@ -97,7 +98,7 @@ export const publicLimiter = withStore({
|
|||||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const webhookLimiter = withStore({
|
export const webhookLimiter = withStore('rl:webhook:', {
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 30,
|
max: 30,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
@@ -107,7 +108,7 @@ export const webhookLimiter = withStore({
|
|||||||
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminLimiter = withStore({
|
export const adminLimiter = withStore('rl:admin:', {
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 100,
|
max: 100,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
@@ -120,7 +121,7 @@ export const adminLimiter = withStore({
|
|||||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const actorLimiter = withStore({
|
export const actorLimiter = withStore('rl:actor:', {
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
max: 240,
|
max: 240,
|
||||||
standardHeaders: 'draft-7',
|
standardHeaders: 'draft-7',
|
||||||
|
|||||||
@@ -76,5 +76,3 @@ export class RedisRateLimitStore implements Store {
|
|||||||
await redis.del(`${this.prefix}${key}`)
|
await redis.del(`${this.prefix}${key}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sharedRateLimitStore = new RedisRateLimitStore('rl:api:')
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Source of truth:
|
|||||||
- `apps/dashboard/src/components/*`
|
- `apps/dashboard/src/components/*`
|
||||||
- `apps/dashboard/src/lib/*`
|
- `apps/dashboard/src/lib/*`
|
||||||
- `apps/dashboard/src/hooks/*`
|
- `apps/dashboard/src/hooks/*`
|
||||||
- `apps/dashboard/src/middleware.ts`
|
- `apps/dashboard/src/proxy.ts`
|
||||||
- `apps/dashboard/next.config.js`
|
- `apps/dashboard/next.config.js`
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -93,7 +93,7 @@ apps/dashboard/
|
|||||||
preferences.ts language/theme persistence scoped per employee
|
preferences.ts language/theme persistence scoped per employee
|
||||||
dashboardPaths.ts normalizes basePath-aware routes
|
dashboardPaths.ts normalizes basePath-aware routes
|
||||||
urls.ts resolves Carplace/admin app links
|
urls.ts resolves Carplace/admin app links
|
||||||
src/middleware.ts auth gate and redirect logic
|
src/proxy.ts auth gate and redirect logic (Next.js 16 proxy convention)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rendering Strategy
|
## Rendering Strategy
|
||||||
@@ -136,7 +136,7 @@ The sign-in page also supports:
|
|||||||
|
|
||||||
### Protected-route middleware
|
### Protected-route middleware
|
||||||
|
|
||||||
`src/middleware.ts` protects the app.
|
`src/proxy.ts` protects the app.
|
||||||
|
|
||||||
Important behavior:
|
Important behavior:
|
||||||
|
|
||||||
@@ -691,7 +691,7 @@ Tradeoffs of this approach:
|
|||||||
When changing the dashboard, keep these rules in mind:
|
When changing the dashboard, keep these rules in mind:
|
||||||
|
|
||||||
1. Preserve the `/dashboard` base path and `toDashboardAppPath` normalization behavior.
|
1. Preserve the `/dashboard` base path and `toDashboardAppPath` normalization behavior.
|
||||||
2. Keep route protection aligned with `src/middleware.ts`.
|
2. Keep route protection aligned with `src/proxy.ts`.
|
||||||
3. If a new page depends on employee role, gate it both in navigation and in-page behavior.
|
3. If a new page depends on employee role, gate it both in navigation and in-page behavior.
|
||||||
4. Reuse `apiFetch` for auth and error handling consistency.
|
4. Reuse `apiFetch` for auth and error handling consistency.
|
||||||
5. Keep language/theme strings inside the dashboard i18n system rather than scattering literals.
|
5. Keep language/theme strings inside the dashboard i18n system rather than scattering literals.
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ function request(input: string, options: { token?: string; headers?: Record<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMiddleware(websiteUrl = 'https://market.example.com') {
|
async function loadProxy(websiteUrl = 'https://market.example.com') {
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
process.env.NEXT_PUBLIC_WEBSITE_URL = websiteUrl
|
process.env.NEXT_PUBLIC_WEBSITE_URL = websiteUrl
|
||||||
return import('./middleware')
|
return import('./proxy')
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -53,19 +53,19 @@ afterEach(() => {
|
|||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dashboard middleware', () => {
|
describe('dashboard proxy', () => {
|
||||||
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
|
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
|
||||||
const { default: middleware } = await loadMiddleware()
|
const { default: proxy } = await loadProxy()
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard/dashboard?x=1') as never)
|
const response = proxy(request('https://workspace.example.com/dashboard/dashboard?x=1') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard?x=1' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard?x=1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects middleware subrequest headers at the app layer', async () => {
|
it('rejects middleware subrequest headers at the app layer', async () => {
|
||||||
const { default: middleware } = await loadMiddleware()
|
const { default: proxy } = await loadProxy()
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard', {
|
const response = proxy(request('https://workspace.example.com/dashboard', {
|
||||||
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
|
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
|
||||||
}) as never)
|
}) as never)
|
||||||
|
|
||||||
@@ -73,25 +73,25 @@ describe('dashboard middleware', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('redirects unauthenticated protected internal-host requests to homepage sign-in on the website origin', async () => {
|
it('redirects unauthenticated protected internal-host requests to homepage sign-in on the website origin', async () => {
|
||||||
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
|
const { default: proxy } = await loadProxy('https://rentaldrivego.example')
|
||||||
|
|
||||||
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
|
const response = proxy(request('http://dashboard:3001/dashboard/team') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Fteam' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Fteam' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
|
it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
|
||||||
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
|
const { default: proxy } = await loadProxy('https://rentaldrivego.example')
|
||||||
|
|
||||||
const response = middleware(request('http://dashboard:3001/reservations') as never)
|
const response = proxy(request('http://dashboard:3001/reservations') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Freservations' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Freservations' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
const { default: proxy } = await loadProxy('https://market.example.com')
|
||||||
|
|
||||||
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
|
const response = proxy(request('http://dashboard:3001/dashboard/billing', {
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-host': 'workspace.customer.example',
|
'x-forwarded-host': 'workspace.customer.example',
|
||||||
'x-forwarded-proto': 'https',
|
'x-forwarded-proto': 'https',
|
||||||
@@ -102,9 +102,9 @@ describe('dashboard middleware', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
||||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
const { default: proxy } = await loadProxy('https://market.example.com')
|
||||||
|
|
||||||
const response = middleware(request('http://dashboard:3001/dashboard/fleet', {
|
const response = proxy(request('http://dashboard:3001/dashboard/fleet', {
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-host': 'api:4000',
|
'x-forwarded-host': 'api:4000',
|
||||||
'x-forwarded-proto': 'https',
|
'x-forwarded-proto': 'https',
|
||||||
@@ -115,34 +115,34 @@ describe('dashboard middleware', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
|
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
|
||||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
const { default: proxy } = await loadProxy('https://market.example.com')
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet&lang=fr&theme=dark') as never)
|
const response = proxy(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet&lang=fr&theme=dark') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects signed-in users away from the sign-in page', async () => {
|
it('redirects signed-in users away from the sign-in page', async () => {
|
||||||
const { default: middleware } = await loadMiddleware()
|
const { default: proxy } = await loadProxy()
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
|
const response = proxy(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/dashboard' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows public dashboard auth pages without a token', async () => {
|
it('allows public dashboard auth pages without a token', async () => {
|
||||||
const { default: middleware } = await loadMiddleware()
|
const { default: proxy } = await loadProxy()
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard/forgot-password') as never)
|
const response = proxy(request('https://workspace.example.com/dashboard/forgot-password') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'next' })
|
expect(response).toEqual({ kind: 'next' })
|
||||||
expect(nextServer.next).toHaveBeenCalledTimes(1)
|
expect(nextServer.next).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows dashboard API requests through so auth endpoints can set and read cookies', async () => {
|
it('allows dashboard API requests through so auth endpoints can set and read cookies', async () => {
|
||||||
const { default: middleware } = await loadMiddleware()
|
const { default: proxy } = await loadProxy()
|
||||||
|
|
||||||
const response = middleware(request('https://workspace.example.com/dashboard/api/v1/auth/employee/login') as never)
|
const response = proxy(request('https://workspace.example.com/dashboard/api/v1/auth/employee/login') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'next' })
|
expect(response).toEqual({ kind: 'next' })
|
||||||
expect(nextServer.redirect).not.toHaveBeenCalled()
|
expect(nextServer.redirect).not.toHaveBeenCalled()
|
||||||
@@ -88,7 +88,7 @@ function isProtectedRoute(req: NextRequest) {
|
|||||||
return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
|
return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function localJwtMiddleware(req: NextRequest): NextResponse {
|
function localJwtProxy(req: NextRequest): NextResponse {
|
||||||
const token = req.cookies.get('employee_session')?.value
|
const token = req.cookies.get('employee_session')?.value
|
||||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
|||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function middleware(req: NextRequest) {
|
export default function proxy(req: NextRequest) {
|
||||||
if (req.headers.has('x-middleware-subrequest')) {
|
if (req.headers.has('x-middleware-subrequest')) {
|
||||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ export default function middleware(req: NextRequest) {
|
|||||||
return NextResponse.redirect(redirectUrl)
|
return NextResponse.redirect(redirectUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
return localJwtMiddleware(req)
|
return localJwtProxy(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
Reference in New Issue
Block a user