Harden platform security, reliability, and CI after full audit.

Close deployment IDOR and gate stub payment endpoints, add production
secret validation, health probes, Redis-backed build progress, GitHub
Actions CI, expanded tests, billing/k8s refactors, and ops runbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-06-29 20:59:49 +03:30
parent a87bc49393
commit 837f0fa63f
83 changed files with 3953 additions and 1308 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios';
import { loginPath } from '@/lib/locale-url';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@@ -55,7 +56,7 @@ api.interceptors.response.use(
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
if (typeof window !== 'undefined') {
window.location.href = '/login';
window.location.href = loginPath();
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Dictionary } from '@/i18n/dictionaries/fa';
export const deploymentStatusBadgeClass: Record<string, string> = {
pending: 'badge-gray',
building: 'badge-yellow',
deploying: 'badge-blue',
running: 'badge-green',
failed: 'badge-red',
build_failed: 'badge-red',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
export const lifecycleStatusClass: Record<string, string> = {
active: 'text-green-600 bg-green-50',
suspended: 'text-amber-700 bg-amber-50',
pending_deletion: 'text-red-700 bg-red-50',
deleted: 'text-gray-500 bg-gray-100',
};
export function deploymentStatusLabel(status: string, t: Dictionary): string {
return (t.dashboard.status as Record<string, string>)[status] ?? status;
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import axios from 'axios';
import { classifyApiError } from './errors';
describe('classifyApiError', () => {
it('classifies network errors', () => {
const err = new axios.AxiosError('Network Error');
expect(classifyApiError(err).kind).toBe('network');
});
it('classifies 403 forbidden', () => {
const err = new axios.AxiosError('Forbidden', undefined, undefined, undefined, {
status: 403,
data: { message: 'Forbidden' },
statusText: 'Forbidden',
headers: {},
config: {} as any,
});
expect(classifyApiError(err).kind).toBe('forbidden');
});
it('classifies generic errors', () => {
expect(classifyApiError(new Error('oops')).kind).toBe('generic');
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import {
formatRemainingForLocale,
isPersianLocale,
parseCpuToMillicores,
} from './format-utils';
describe('isPersianLocale', () => {
it('matches fa-IR and legacy fa', () => {
expect(isPersianLocale('fa-IR')).toBe(true);
expect(isPersianLocale('fa')).toBe(true);
expect(isPersianLocale('en-US')).toBe(false);
});
});
describe('formatRemainingForLocale', () => {
it('uses Persian formatting for fa-IR', () => {
const future = new Date(Date.now() + 2 * 86400000 + 3600000);
const result = formatRemainingForLocale(future, 'fa-IR');
expect(result).toMatch(/روز/);
});
it('uses English formatting for en-US', () => {
const future = new Date(Date.now() + 2 * 86400000);
const result = formatRemainingForLocale(future, 'en-US');
expect(result).toMatch(/\d+d/);
});
});
describe('parseCpuToMillicores', () => {
it('parses millicores and cores', () => {
expect(parseCpuToMillicores('500m')).toBe(500);
expect(parseCpuToMillicores('1')).toBe(1000);
});
});
+6 -3
View File
@@ -19,7 +19,10 @@ export function parseMemoryToMi(mem: string): number {
return parseFloat(mem);
}
/** Human-readable time left (days, hours, minutes). */
/** True when locale is Persian (fa-IR or legacy "fa"). */
export function isPersianLocale(locale: string): boolean {
return locale === 'fa' || locale.startsWith('fa-');
}
export function formatRemainingDurationMs(remainingMs: number): string {
const ms = Math.max(0, remainingMs);
const days = Math.floor(ms / 86400000);
@@ -54,7 +57,7 @@ export function formatRemainingForLocale(
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
if (Number.isNaN(date.getTime())) return '—';
const remainingMs = date.getTime() - Date.now();
return locale === 'fa'
return isPersianLocale(locale)
? formatRemainingDurationFa(remainingMs)
: formatRemainingDurationMs(remainingMs);
}
@@ -69,7 +72,7 @@ export function formatExpiresAtLocal(
): string {
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
if (Number.isNaN(date.getTime())) return '—';
if (locale === 'fa') {
if (isPersianLocale(locale || '')) {
// Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless
// of the runtime's ICU pattern data.
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getClientLocale, localizedPath, loginPath } from './locale-url';
import { defaultLocale, LOCALE_COOKIE } from '@/i18n/config';
describe('locale-url', () => {
const originalDocument = global.document;
beforeEach(() => {
// jsdom-less: stub document.cookie
Object.defineProperty(global, 'document', {
value: { cookie: '' },
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(global, 'document', {
value: originalDocument,
configurable: true,
});
});
it('returns default locale when cookie is missing', () => {
expect(getClientLocale()).toBe(defaultLocale);
});
it('reads locale from NEXT_LOCALE cookie', () => {
document.cookie = `${LOCALE_COOKIE}=en-US`;
expect(getClientLocale()).toBe('en-US');
});
it('builds localized paths', () => {
expect(localizedPath('/dashboard/wallet', 'fa-IR')).toBe('/fa-IR/dashboard/wallet');
expect(loginPath('en-US')).toBe('/en-US/login');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config';
/** Read the active locale from cookie (client-only). */
export function getClientLocale(): Locale {
if (typeof document === 'undefined') {
return defaultLocale;
}
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`));
const value = match ? decodeURIComponent(match[1]) : '';
return isLocale(value) ? value : defaultLocale;
}
/** Build an absolute URL with the locale prefix, e.g. /fa-IR/dashboard/wallet */
export function localizedPath(path: string, locale?: Locale): string {
const loc = locale ?? getClientLocale();
const normalized = path.startsWith('/') ? path : `/${path}`;
return `/${loc}${normalized}`;
}
/** Absolute origin + localized path for payment gateway callbacks. */
export function localizedCallbackUrl(path: string, locale?: Locale): string {
if (typeof window === 'undefined') {
return localizedPath(path, locale);
}
return `${window.location.origin}${localizedPath(path, locale)}`;
}
/** Localized login path for auth redirects. */
export function loginPath(locale?: Locale): string {
return localizedPath('/login', locale);
}
+7
View File
@@ -0,0 +1,7 @@
/** Central React Query keys — keep invalidation consistent across pages. */
export const queryKeys = {
walletBalance: ['wallet-balance'] as const,
walletTransactions: ['wallet-transactions'] as const,
applications: (productType?: string) =>
productType ? (['applications', productType] as const) : (['applications'] as const),
};