chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { TokenManager } from '@/infra/security/token.manager.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES, type RoleSchema } from '@insforge/shared-schemas';
|
||||
import { NEXT_ACTIONS } from '../../utils/next-actions.js';
|
||||
import { SecretService } from '@/services/secrets/secret.service.js';
|
||||
|
||||
export type UserContext = {
|
||||
/**
|
||||
* Always present at the API level: user UUID for authenticated, admin id
|
||||
* for project_admin, 'anonymous' for anon. Only the authenticated UUID is
|
||||
* a row-ownership identity; database boundaries (claims, owner columns)
|
||||
* gate on role === 'authenticated' so admin/anon labels never reach
|
||||
* uuid-typed claims or columns.
|
||||
*/
|
||||
id: string;
|
||||
email?: string;
|
||||
role: RoleSchema;
|
||||
};
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: UserContext;
|
||||
authenticated?: boolean;
|
||||
hasApiKey?: boolean;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const tokenManager = TokenManager.getInstance();
|
||||
const secretService = SecretService.getInstance();
|
||||
|
||||
// Helper function to extract Bearer token (exported for optional auth checks)
|
||||
export function extractBearerToken(authHeader: string | undefined): string | null {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Helper function to extract API key from request
|
||||
// Checks both Bearer token (if starts with 'ik_') and x-api-key header
|
||||
export function extractApiKey(req: AuthRequest): string | null {
|
||||
const bearerToken = extractBearerToken(req.headers.authorization);
|
||||
if (bearerToken && bearerToken.startsWith('ik_')) {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
// Fall back to x-api-key header for backward compatibility
|
||||
if (req.headers['x-api-key']) {
|
||||
return req.headers['x-api-key'] as string;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to extract the opaque anon key from a request
|
||||
// The anon key travels in the Authorization header like every other client
|
||||
// credential (Bearer anon_...); signed-in clients replace it with their JWT.
|
||||
export function extractAnonKey(req: AuthRequest): string | null {
|
||||
const bearerToken = extractBearerToken(req.headers.authorization);
|
||||
if (bearerToken && bearerToken.startsWith('anon_')) {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to set user on request
|
||||
function setRequestUser(
|
||||
req: AuthRequest,
|
||||
payload: { sub: string; email?: string; role: RoleSchema }
|
||||
) {
|
||||
req.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies user authentication (accepts API keys, anon keys, and JWT tokens)
|
||||
*
|
||||
* All credentials travel in the Authorization header; dispatch is by shape,
|
||||
* never by falling back on failure:
|
||||
* - `ik_...` (Bearer or x-api-key) -> admin API key
|
||||
* - Bearer `anon_...` -> opaque anon key, `anon` role
|
||||
* - any other Bearer -> JWT (role claim decides admin/authenticated/legacy anon)
|
||||
*
|
||||
* Signed-out clients send the anon key as their Bearer credential; signing in
|
||||
* replaces it with the user JWT. Each branch fails closed: an invalid or
|
||||
* expired user JWT must return 401 (so SDK refresh flows trigger) — it must
|
||||
* never silently downgrade to anon.
|
||||
*/
|
||||
export async function verifyUser(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const apiKey = extractApiKey(req);
|
||||
if (apiKey) {
|
||||
return verifyApiKey(req, res, next);
|
||||
}
|
||||
|
||||
const bearerToken = extractBearerToken(req.headers.authorization);
|
||||
if (bearerToken && bearerToken.startsWith('anon_')) {
|
||||
return verifyAnonKey(req, res, next);
|
||||
}
|
||||
|
||||
// Anything else (including no credentials) goes through JWT verification,
|
||||
// which produces the canonical 401 when the header is missing or invalid
|
||||
return verifyToken(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies admin authentication (requires admin token)
|
||||
*/
|
||||
export async function verifyAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const apiKey = extractApiKey(req);
|
||||
if (apiKey) {
|
||||
return verifyApiKey(req, res, next);
|
||||
}
|
||||
|
||||
try {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
if (!token) {
|
||||
throw new AppError(
|
||||
'No admin token provided',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
// For admin, we use JWT tokens
|
||||
const payload = tokenManager.verifyToken(token);
|
||||
|
||||
if (payload.role !== 'project_admin') {
|
||||
throw new AppError(
|
||||
'Admin access required',
|
||||
403,
|
||||
ERROR_CODES.AUTH_UNAUTHORIZED,
|
||||
NEXT_ACTIONS.CHECK_ADMIN_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
setRequestUser(req, payload);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
'Invalid admin token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_ADMIN_TOKEN
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies API key authentication
|
||||
* Accepts API key via Authorization: Bearer header or x-api-key header (backward compatibility)
|
||||
*/
|
||||
export async function verifyApiKey(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract API key from request using helper
|
||||
const apiKey = extractApiKey(req);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new AppError(
|
||||
'No API key provided',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_API_KEY,
|
||||
NEXT_ACTIONS.CHECK_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await secretService.verifyApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
throw new AppError(
|
||||
'Invalid API key',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_API_KEY,
|
||||
NEXT_ACTIONS.CHECK_API_KEY
|
||||
);
|
||||
}
|
||||
req.authenticated = true;
|
||||
req.hasApiKey = true;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the opaque anon key (`anon_...`)
|
||||
* Maps the request to the `anon` role; RLS policies are the security boundary.
|
||||
* The request carries 'anonymous' as its API-level subject, but no sub claim
|
||||
* reaches the database (stripped like admin subjects), so auth.uid() is NULL
|
||||
* and identity-scoped policies fail closed.
|
||||
*/
|
||||
export async function verifyAnonKey(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
try {
|
||||
const anonKey = extractAnonKey(req);
|
||||
|
||||
if (!anonKey) {
|
||||
throw new AppError(
|
||||
'No anon key provided',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await secretService.verifyAnonKey(anonKey);
|
||||
if (!isValid) {
|
||||
throw new AppError(
|
||||
'Invalid anon key',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
setRequestUser(req, { sub: 'anonymous', role: 'anon' });
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core token verification middleware that handles JWT tokens
|
||||
* Sets req.user with the authenticated user information
|
||||
*/
|
||||
export function verifyToken(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
if (!token) {
|
||||
throw new AppError(
|
||||
'No token provided',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const payload = tokenManager.verifyToken(token);
|
||||
|
||||
// Validate token has a role
|
||||
if (!payload.role) {
|
||||
throw new AppError(
|
||||
'Invalid token: missing role',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
// Set user info on request
|
||||
setRequestUser(req, payload);
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
'Invalid token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies JWT token from cloud backend (api.insforge.dev)
|
||||
* Validates signature using JWKS and checks project_id claim
|
||||
*/
|
||||
export async function verifyCloudBackend(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
if (!token) {
|
||||
throw new AppError(
|
||||
'No authorization token provided',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
);
|
||||
}
|
||||
|
||||
// Use TokenManager to verify cloud token
|
||||
const { projectId } = await tokenManager.verifyCloudToken(token);
|
||||
|
||||
// Set project_id on request for use in route handlers
|
||||
req.projectId = projectId;
|
||||
req.authenticated = true;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
'Invalid cloud backend token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS,
|
||||
NEXT_ACTIONS.CHECK_TOKEN
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { DatabaseError } from 'pg';
|
||||
import { errorResponse } from '@/utils/response.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { AppError, getDatabaseErrorDetails, isErrorObject } from '@/utils/errors.js';
|
||||
|
||||
export function errorMiddleware(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
// Only log non-authentication errors or unexpected errors
|
||||
if (!(err instanceof AppError && err.statusCode === 401)) {
|
||||
logger.error('Error occurred', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle known AppError instances
|
||||
if (err instanceof AppError) {
|
||||
return errorResponse(res, err.code, err.message, err.statusCode, err.nextActions);
|
||||
}
|
||||
|
||||
// Handle SyntaxError from JSON.parse
|
||||
if (err instanceof SyntaxError && err.message.includes('JSON')) {
|
||||
return errorResponse(
|
||||
res,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
err.message,
|
||||
400,
|
||||
'Please ensure your request body contains valid JSON'
|
||||
);
|
||||
}
|
||||
|
||||
// Handle PostgreSQL database errors
|
||||
if (err instanceof DatabaseError) {
|
||||
const dbError = getDatabaseErrorDetails(err);
|
||||
if (dbError) {
|
||||
return errorResponse(
|
||||
res,
|
||||
dbError.code,
|
||||
dbError.message,
|
||||
dbError.statusCode,
|
||||
dbError.nextActions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For all other errors, check if it's an object we can work with
|
||||
if (!isErrorObject(err)) {
|
||||
return errorResponse(res, ERROR_CODES.INTERNAL_ERROR, 'Internal server error', 500);
|
||||
}
|
||||
|
||||
// Handle JSON parsing errors from body-parser
|
||||
if (err.type === 'entity.parse.failed' && err.status === 400) {
|
||||
return errorResponse(
|
||||
res,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
err.message || 'Invalid JSON in request body',
|
||||
400,
|
||||
'Please ensure your request body contains valid JSON'
|
||||
);
|
||||
}
|
||||
|
||||
// Default internal error with optional message
|
||||
const message = err.message || 'Internal server error';
|
||||
return errorResponse(res, ERROR_CODES.INTERNAL_ERROR, message, 500);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
|
||||
/**
|
||||
* Store for tracking per-email cooldowns
|
||||
* Maps email -> last request timestamp
|
||||
*/
|
||||
const emailCooldowns = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Cleanup interval reference for graceful shutdown
|
||||
*/
|
||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Cleanup old cooldown entries every 5 minutes
|
||||
*/
|
||||
cleanupInterval = setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
for (const [email, timestamp] of emailCooldowns.entries()) {
|
||||
if (now - timestamp > fiveMinutes) {
|
||||
emailCooldowns.delete(email);
|
||||
}
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
/**
|
||||
* Clean up resources for graceful shutdown
|
||||
*/
|
||||
export function destroyEmailCooldownInterval(): void {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
emailCooldowns.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for email otp requests
|
||||
* Prevents brute-force attacks, resource exhaustion, and enumeration from single IP
|
||||
*
|
||||
* Limits: 5 requests per 15 minutes per IP
|
||||
* Counts ALL requests (both successful and failed) to prevent abuse
|
||||
*/
|
||||
export const sendEmailOTPRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Limit each IP to 5 requests per windowMs
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
handler: (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next(
|
||||
new AppError(
|
||||
'Too many send email verification requests from this IP. Please try again in 15 minutes.',
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
)
|
||||
);
|
||||
},
|
||||
// Count all requests (both successes and failures) to prevent resource exhaustion and enumeration
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for S3 access key management endpoints.
|
||||
* These endpoints mint / revoke long-lived credentials, so tight limits
|
||||
* prevent credential spraying or key-churn abuse from a single IP.
|
||||
*
|
||||
* Limits: 20 requests per 15 minutes per IP (shared across POST/GET/DELETE).
|
||||
*/
|
||||
export const s3AccessKeyManagementRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next(
|
||||
new AppError(
|
||||
'Too many S3 access key management requests from this IP. Please try again in 15 minutes.',
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
)
|
||||
);
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for the compute logs endpoint.
|
||||
* Unlike the write limiters, this is a read endpoint the dashboard polls every
|
||||
* ~2s while live-tailing, so the budget is generous — it exists to cap retry
|
||||
* storms / abuse, not to throttle normal tailing (≈30 req/min) across a few
|
||||
* open tabs.
|
||||
*
|
||||
* Limits: 120 requests per minute per IP.
|
||||
*/
|
||||
export const computeLogsRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 120,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next(
|
||||
new AppError(
|
||||
'Too many log requests from this IP. Please slow down and try again shortly.',
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
)
|
||||
);
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for email OTP verification attempts
|
||||
* Prevents brute-force code guessing
|
||||
*
|
||||
* Limits: 10 attempts per 15 minutes per IP
|
||||
*/
|
||||
export const verifyOTPRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Limit each IP to 10 verification attempts per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next(
|
||||
new AppError(
|
||||
'Too many verification attempts from this IP. Please try again in 15 minutes.',
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
)
|
||||
);
|
||||
},
|
||||
skipSuccessfulRequests: true, // Don't count successful verifications
|
||||
skipFailedRequests: false, // Count failed attempts to prevent brute force
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-email cooldown middleware
|
||||
* Prevents enumeration attacks by enforcing minimum time between requests for same email
|
||||
*
|
||||
* Cooldown: 60 seconds between requests for same email
|
||||
*/
|
||||
export const perEmailCooldown = (cooldownMs: number = 60000) => {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
const email = req.body?.email?.toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
// If no email in body, let it pass (will be caught by validation)
|
||||
return next();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastRequest = emailCooldowns.get(email);
|
||||
|
||||
if (lastRequest && now - lastRequest < cooldownMs) {
|
||||
const remainingMs = cooldownMs - (now - lastRequest);
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
|
||||
throw new AppError(
|
||||
`Please wait ${remainingSec} seconds before requesting another code for this email`,
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
// Update last request time
|
||||
emailCooldowns.set(email, now);
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined rate limiter for sending email otp requests
|
||||
* Applies both per-IP and per-email limits
|
||||
*/
|
||||
export const sendEmailOTPLimiter = [
|
||||
sendEmailOTPRateLimiter,
|
||||
perEmailCooldown(60000), // 60 second cooldown per email
|
||||
];
|
||||
|
||||
/**
|
||||
* Rate limiter for OTP verification attempts (email OTP verification)
|
||||
* Only per-IP limit, no per-email limit (to allow legitimate retries)
|
||||
*/
|
||||
export const verifyOTPLimiter = [verifyOTPRateLimiter];
|
||||
|
||||
/**
|
||||
* Per-IP rate limiters for "write" endpoints that ultimately drive an external
|
||||
* provider call.
|
||||
*
|
||||
* Goal: stop a single admin's runaway script from monopolising the platform's
|
||||
* shared upstream provider quotas — Vercel `Token creation 32/hr`, Vercel
|
||||
* `Deployments per 5min: 120`, Fly `app deletions: 100/min`, Deno
|
||||
* `Deployments per hour: 60`, etc.
|
||||
*
|
||||
* Each provider category gets its own bucket so a noisy compute deploy loop
|
||||
* cannot starve a legitimate function update (and vice versa). The defaults
|
||||
* (see DEFAULT_WRITE_ENDPOINT_LIMITS) are generous for human-driven CRUD;
|
||||
* CI loops are expected to deploy once per commit and stay well below them.
|
||||
*
|
||||
* Operators can override per-category budgets at runtime by uploading a JSON
|
||||
* file to the AWS_CONFIG_BUCKET at key `resource-rate-limits.json`:
|
||||
* { "functions": 20, "deployments": 40, "compute": 15 }
|
||||
* The file is fetched on startup and refreshed on a periodic timer
|
||||
* (default 1 hour; tune via INSFORGE_WRITE_RATE_LIMIT_REFRESH_MS, set to 0
|
||||
* for startup-only). Any missing or invalid (non-positive-integer) field
|
||||
* falls back to the built-in default.
|
||||
*
|
||||
* Counts ALL requests (skipFailedRequests: false) so a buggy script that
|
||||
* loops on a 4xx response can't bypass the cap.
|
||||
*
|
||||
* Within a category, the budget is shared across every wired endpoint — e.g.
|
||||
* a deploy create + an env-var write + a domain add all count toward the
|
||||
* same per-IP `deployments` budget.
|
||||
*
|
||||
* E2E suites that exercise many write endpoints from one IP can opt out by
|
||||
* setting `INSFORGE_DISABLE_WRITE_RATE_LIMIT=1`. The check is deliberately
|
||||
* an explicit named flag (not `NODE_ENV`) so unit tests still exercise the
|
||||
* limiter and prod can never accidentally bypass via test envs.
|
||||
*/
|
||||
export type WriteLimiterCategory = 'functions' | 'deployments' | 'compute';
|
||||
|
||||
function isWriteRateLimitDisabled(): boolean {
|
||||
return process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category default budgets used when no S3 override is loaded. These are
|
||||
* the values the limiter uses out-of-the-box and the fallback whenever the
|
||||
* S3 config file is absent, unreadable, or missing a category.
|
||||
*/
|
||||
export const DEFAULT_WRITE_ENDPOINT_LIMITS: Readonly<Record<WriteLimiterCategory, number>> =
|
||||
Object.freeze({
|
||||
functions: 15,
|
||||
deployments: 25,
|
||||
compute: 15,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutable copy of the active per-category budgets. Reads happen on every
|
||||
* request via `getWriteEndpointLimit`; writes happen on startup and during
|
||||
* the periodic S3 refresh.
|
||||
*/
|
||||
const currentWriteEndpointLimits: Record<WriteLimiterCategory, number> = {
|
||||
...DEFAULT_WRITE_ENDPOINT_LIMITS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Public URL of the live override file. Expected shape:
|
||||
* `{ "functions"?: number, "deployments"?: number, "compute"?: number }`.
|
||||
* Any missing or invalid (non-positive-integer) field falls back to the default.
|
||||
*
|
||||
* Self-hosters can pin their own config by setting
|
||||
* INSFORGE_WRITE_RATE_LIMIT_CONFIG_URL. Plain HTTPS is used instead of the
|
||||
* AWS SDK so the fetch works on instances without AWS credentials and never
|
||||
* gets rejected because the runtime's signing identity lacks read access to
|
||||
* a public bucket.
|
||||
*/
|
||||
const DEFAULT_WRITE_ENDPOINT_LIMITS_URL = 'https://config.insforge.dev/resource-rate-limits.json';
|
||||
|
||||
function getWriteEndpointLimitsUrl(): string {
|
||||
return process.env.INSFORGE_WRITE_RATE_LIMIT_CONFIG_URL || DEFAULT_WRITE_ENDPOINT_LIMITS_URL;
|
||||
}
|
||||
|
||||
const WRITE_ENDPOINT_LIMITS_FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function getWriteEndpointLimit(category: WriteLimiterCategory): number {
|
||||
return currentWriteEndpointLimits[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure merge step: validate the partial config and update the live map.
|
||||
* Exported for tests so they can simulate an S3-driven override without
|
||||
* actually touching S3.
|
||||
*/
|
||||
export function applyWriteEndpointLimits(
|
||||
partial: Partial<Record<WriteLimiterCategory, unknown>>
|
||||
): void {
|
||||
for (const category of Object.keys(DEFAULT_WRITE_ENDPOINT_LIMITS) as WriteLimiterCategory[]) {
|
||||
const raw = partial[category];
|
||||
if (raw === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0) {
|
||||
currentWriteEndpointLimits[category] = raw;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Ignoring invalid write-endpoint rate limit for "${category}": expected positive integer, got ${JSON.stringify(raw)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all categories back to defaults. Used by the refresh loop so a
|
||||
* previously-set override that is later removed from S3 reverts cleanly,
|
||||
* and by tests for isolation.
|
||||
*/
|
||||
export function resetWriteEndpointLimitsToDefaults(): void {
|
||||
Object.assign(currentWriteEndpointLimits, DEFAULT_WRITE_ENDPOINT_LIMITS);
|
||||
}
|
||||
|
||||
async function fetchWriteEndpointLimitsConfig(): Promise<Partial<
|
||||
Record<WriteLimiterCategory, unknown>
|
||||
> | null> {
|
||||
const url = getWriteEndpointLimitsUrl();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(WRITE_ENDPOINT_LIMITS_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// 404 is the "no override published" case and is expected; anything
|
||||
// else is worth surfacing so operators notice misconfigured policies.
|
||||
if (response.status !== 404) {
|
||||
logger.warn(
|
||||
`Failed to fetch write-endpoint rate-limit config from ${url}: HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as Partial<Record<WriteLimiterCategory, unknown>>;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch write-endpoint rate-limit config from ${url}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWriteEndpointLimitsFromS3(): Promise<void> {
|
||||
const config = await fetchWriteEndpointLimitsConfig();
|
||||
// Always reset first so a category dropped from the file reverts to its
|
||||
// default rather than sticking at the last fetched value.
|
||||
resetWriteEndpointLimitsToDefaults();
|
||||
if (config && typeof config === 'object') {
|
||||
applyWriteEndpointLimits(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default cadence at which each backend instance refetches the override
|
||||
* file. Rate-limit policy changes don't need sub-minute propagation, and a
|
||||
* lower cadence keeps the per-bucket request volume modest when many
|
||||
* self-hosted instances are running.
|
||||
*
|
||||
* Override via INSFORGE_WRITE_RATE_LIMIT_REFRESH_MS (e.g. `300000` for the
|
||||
* old 5-minute cadence, or `0` to disable periodic refresh and only fetch
|
||||
* once at startup).
|
||||
*/
|
||||
const DEFAULT_WRITE_ENDPOINT_LIMITS_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function getWriteEndpointLimitsRefreshMs(): number {
|
||||
const raw = process.env.INSFORGE_WRITE_RATE_LIMIT_REFRESH_MS;
|
||||
if (raw === undefined) {
|
||||
return DEFAULT_WRITE_ENDPOINT_LIMITS_REFRESH_MS;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
logger.warn(
|
||||
`Ignoring invalid INSFORGE_WRITE_RATE_LIMIT_REFRESH_MS=${JSON.stringify(raw)} ` +
|
||||
`(expected non-negative integer); using default ${DEFAULT_WRITE_ENDPOINT_LIMITS_REFRESH_MS}ms`
|
||||
);
|
||||
return DEFAULT_WRITE_ENDPOINT_LIMITS_REFRESH_MS;
|
||||
}
|
||||
|
||||
let writeLimitsRefreshTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Kick off the initial S3 fetch and start the periodic refresh. Safe to call
|
||||
* more than once — subsequent calls are no-ops while a refresh is already
|
||||
* scheduled. Uses recursive setTimeout (rather than setInterval) so each
|
||||
* cycle re-reads the env-configurable cadence and adds a small jitter,
|
||||
* preventing a fleet of co-deployed instances from stampeding the bucket
|
||||
* on the same schedule.
|
||||
*/
|
||||
export function startWriteEndpointLimitsRefresh(): void {
|
||||
if (writeLimitsRefreshTimeout) {
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget initial load: defaults remain in effect until it resolves.
|
||||
void loadWriteEndpointLimitsFromS3();
|
||||
const scheduleNext = () => {
|
||||
const base = getWriteEndpointLimitsRefreshMs();
|
||||
if (base === 0) {
|
||||
// Operator opted out of periodic refresh — startup fetch only.
|
||||
return;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * base * 0.1);
|
||||
writeLimitsRefreshTimeout = setTimeout(() => {
|
||||
void loadWriteEndpointLimitsFromS3();
|
||||
scheduleNext();
|
||||
}, base + jitter);
|
||||
// Don't keep the event loop alive just for config polling.
|
||||
writeLimitsRefreshTimeout.unref?.();
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
export function destroyWriteEndpointLimitsRefresh(): void {
|
||||
if (writeLimitsRefreshTimeout) {
|
||||
clearTimeout(writeLimitsRefreshTimeout);
|
||||
writeLimitsRefreshTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the live S3 refresh under vitest so unit tests get deterministic
|
||||
// defaults and don't fire network calls on import.
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
startWriteEndpointLimitsRefresh();
|
||||
}
|
||||
|
||||
function createWriteEndpointLimiter(category: WriteLimiterCategory) {
|
||||
return rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
// Function form so the limiter picks up the latest S3-driven value on
|
||||
// every request without needing to rebuild the middleware.
|
||||
max: () => getWriteEndpointLimit(category),
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: () => isWriteRateLimitDisabled(),
|
||||
handler: (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next(
|
||||
new AppError(
|
||||
`Too many ${category} write requests. Please wait a few minutes and try again.`,
|
||||
429,
|
||||
ERROR_CODES.TOO_MANY_REQUESTS
|
||||
)
|
||||
);
|
||||
},
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const functionsWriteLimiter = createWriteEndpointLimiter('functions');
|
||||
export const deploymentsWriteLimiter = createWriteEndpointLimiter('deployments');
|
||||
export const computeWriteLimiter = createWriteEndpointLimiter('compute');
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { S3AccessKeyService } from '@/services/storage/s3-access-key.service.js';
|
||||
import { verifyHeaderSignature } from '@/services/storage/s3-signature.js';
|
||||
import { sendS3Error } from '@/api/routes/s3-gateway/errors.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
|
||||
/**
|
||||
* Region used to validate the `Credential=<ak>/<date>/<region>/s3/aws4_request`
|
||||
* scope in incoming Authorization headers. Shares `AWS_REGION` with
|
||||
* S3StorageProvider so clients sign with the same region the backing bucket
|
||||
* lives in (and so `GET /api/storage/s3/config` surfaces exactly what the
|
||||
* middleware will accept). Defaults to `us-east-2`.
|
||||
*/
|
||||
const SIGNING_REGION = appConfig.storage.awsRegion;
|
||||
const MAX_CLOCK_SKEW_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Return the raw, percent-encoded path as the client sent it, *including* the
|
||||
* gateway mount prefix. Express's `req.path` is URL-decoded (so `hello%20x`
|
||||
* becomes `hello x`) which breaks SigV4 canonicalization for object keys with
|
||||
* percent-encoded chars. SigV4 also requires the canonical URI to match what
|
||||
* the client signed, which is the absolute HTTP path — i.e. the prefix is
|
||||
* part of the signed bytes and must not be stripped before verification.
|
||||
*/
|
||||
function rawRequestPath(req: Request): string {
|
||||
const full = req.originalUrl || req.url;
|
||||
const qIdx = full.indexOf('?');
|
||||
return qIdx === -1 ? full : full.slice(0, qIdx);
|
||||
}
|
||||
|
||||
export interface S3AuthContext {
|
||||
accessKeyId: string;
|
||||
s3AccessKeyRowId: string;
|
||||
signingKey: Buffer;
|
||||
datetime: string;
|
||||
scope: string;
|
||||
seedSignature: string;
|
||||
requestId: string;
|
||||
payloadHash: string;
|
||||
}
|
||||
|
||||
export interface S3AuthenticatedRequest extends Request {
|
||||
s3Auth: S3AuthContext;
|
||||
}
|
||||
|
||||
function parseAmzDate(s: string): Date | null {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(s);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
|
||||
}
|
||||
|
||||
export async function s3Sigv4Middleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const requestId = crypto.randomUUID();
|
||||
(req as Request & { s3RequestId?: string }).s3RequestId = requestId;
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (typeof authHeader !== 'string' || !authHeader.startsWith('AWS4-HMAC-SHA256')) {
|
||||
sendS3Error(res, 'AuthorizationHeaderMalformed', 'Missing or invalid Authorization header', {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const amzDate = req.headers['x-amz-date'];
|
||||
if (typeof amzDate !== 'string') {
|
||||
sendS3Error(res, 'AuthorizationHeaderMalformed', 'Missing x-amz-date header', {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parsed = parseAmzDate(amzDate);
|
||||
if (!parsed || Math.abs(Date.now() - parsed.getTime()) > MAX_CLOCK_SKEW_MS) {
|
||||
sendS3Error(res, 'RequestTimeTooSkewed', 'Clock skew exceeds 15 minutes', {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadHash = (req.headers['x-amz-content-sha256'] as string) ?? 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const credMatch = /Credential=([^/]+)\//.exec(authHeader);
|
||||
if (!credMatch) {
|
||||
sendS3Error(res, 'AuthorizationHeaderMalformed', 'Missing Credential in Authorization', {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const accessKeyId = credMatch[1];
|
||||
|
||||
const svc = S3AccessKeyService.getInstance();
|
||||
const resolved = await svc.resolveAccessKeyForVerification(accessKeyId);
|
||||
if (!resolved) {
|
||||
sendS3Error(res, 'InvalidAccessKeyId', `The access key ${accessKeyId} does not exist`, {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (typeof v === 'string') {
|
||||
headers[k.toLowerCase()] = v;
|
||||
} else if (Array.isArray(v)) {
|
||||
headers[k.toLowerCase()] = v.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
const rawUrl = req.originalUrl || req.url;
|
||||
const query = rawUrl.includes('?') ? rawUrl.slice(rawUrl.indexOf('?') + 1) : '';
|
||||
const result = verifyHeaderSignature({
|
||||
authorization: authHeader,
|
||||
secret: resolved.secret,
|
||||
method: req.method,
|
||||
path: rawRequestPath(req),
|
||||
query,
|
||||
headers,
|
||||
payloadHash,
|
||||
expectedRegion: SIGNING_REGION,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
sendS3Error(res, result.code, result.reason, {
|
||||
resource: req.path,
|
||||
requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget last_used_at update.
|
||||
setImmediate(() => {
|
||||
svc
|
||||
.touchLastUsed(resolved.id)
|
||||
.catch((err) => logger.warn('Failed to update last_used_at', { err, accessKeyId }));
|
||||
});
|
||||
|
||||
(req as S3AuthenticatedRequest).s3Auth = {
|
||||
accessKeyId,
|
||||
s3AccessKeyRowId: resolved.id,
|
||||
signingKey: result.signingKey,
|
||||
datetime: result.datetime,
|
||||
scope: result.scope,
|
||||
seedSignature: result.seedSignature,
|
||||
requestId,
|
||||
payloadHash,
|
||||
};
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import multer from 'multer';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import { ProcessedFormData } from '@/types/storage.js';
|
||||
import { StorageConfigService } from '@/services/storage/storage-config.service.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
|
||||
// Constants
|
||||
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Returns the configured max file size in bytes.
|
||||
* Uses the MAX_FILE_SIZE environment variable if set, otherwise defaults to 50 MB.
|
||||
*/
|
||||
export const getMaxFileSize = (): number => appConfig.server.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
||||
|
||||
// Create multer instance with memory storage (static, env-based — used by non-storage routes)
|
||||
export const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: getMaxFileSize(),
|
||||
files: appConfig.server.maxFilesPerField,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a per-request multer single-file upload middleware that reads the
|
||||
* configured max file size from the storage config table at request time.
|
||||
* Falls back to the env-based limit when the database is unreachable.
|
||||
*/
|
||||
export const dynamicUploadSingle =
|
||||
(fieldName: string) =>
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
let maxSize: number;
|
||||
try {
|
||||
maxSize = await StorageConfigService.getInstance().getMaxFileSizeBytes();
|
||||
} catch (error) {
|
||||
// Fall back to env-based limit when the DB is unreachable
|
||||
logger.warn('Could not read storage config from DB, falling back to env/default', { error });
|
||||
maxSize = getMaxFileSize();
|
||||
}
|
||||
|
||||
// Attach the resolved limit to the request so handleUploadError can reference it
|
||||
(req as Request & { _maxFileSizeBytes?: number })._maxFileSizeBytes = maxSize;
|
||||
|
||||
const uploader = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: maxSize,
|
||||
files: appConfig.server.maxFilesPerField,
|
||||
},
|
||||
}).single(fieldName);
|
||||
uploader(req, res, next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Express error-handling middleware for multer upload errors.
|
||||
* Translates multer-specific errors into user-friendly AppError responses,
|
||||
* including the configured size limit when a file exceeds the maximum.
|
||||
*/
|
||||
export const handleUploadError = (
|
||||
err: Error | multer.MulterError,
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
const maxBytes = (req as Request & { _maxFileSizeBytes?: number })._maxFileSizeBytes;
|
||||
const limitMb = maxBytes ? Math.round(maxBytes / (1024 * 1024)) : null;
|
||||
const message = limitMb
|
||||
? `File too large. Maximum upload size is ${limitMb} MB.`
|
||||
: 'File too large. Please check the configured upload size limit.';
|
||||
return next(new AppError(message, 413, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
}
|
||||
|
||||
const errorMap: Record<string, { status: number; message: string }> = {
|
||||
LIMIT_FILE_COUNT: { status: 400, message: 'Too many files' },
|
||||
};
|
||||
|
||||
const error = errorMap[err.code] || { status: 400, message: err.message };
|
||||
return next(new AppError(error.message, error.status, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return next(new AppError(err.message, 500, ERROR_CODES.INTERNAL_ERROR));
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts fields and files from a multipart form-data request
|
||||
* processed by multer and returns them as a structured object.
|
||||
*/
|
||||
export function processFormData(req: Request): ProcessedFormData {
|
||||
const fields = req.body || {};
|
||||
const files: Record<string, Express.Multer.File[]> = {};
|
||||
|
||||
if (req.files) {
|
||||
if (Array.isArray(req.files)) {
|
||||
files['files'] = req.files;
|
||||
} else {
|
||||
Object.assign(files, req.files);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, files };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { DatabaseAdvisorService } from '@/services/database/database-advisor.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { SUPPRESSION_SCOPES, SUPPRESSION_REASONS } from '@/types/advisor.js';
|
||||
|
||||
const router = Router();
|
||||
const advisorService = DatabaseAdvisorService.getInstance();
|
||||
|
||||
router.post('/scan', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scanId = await advisorService.triggerScan('manual');
|
||||
successResponse(res, { scanId, message: 'Scan started' }, 201);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Trigger advisor scan error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the latest advisor scan summary.
|
||||
* GET /api/advisor/latest
|
||||
*/
|
||||
router.get('/latest', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const summary = await advisorService.getLatestScan();
|
||||
successResponse(res, summary);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get latest advisor scan error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get findings for the latest advisor scan.
|
||||
* GET /api/advisor/issues
|
||||
*/
|
||||
router.get('/issues', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const severity = req.query.severity as string | undefined;
|
||||
if (severity !== undefined && !['critical', 'warning', 'info'].includes(severity)) {
|
||||
throw new AppError(
|
||||
'Invalid severity parameter: must be one of critical, warning, info',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const category = req.query.category as string | undefined;
|
||||
if (category !== undefined && !['security', 'performance', 'health'].includes(category)) {
|
||||
throw new AppError(
|
||||
'Invalid category parameter: must be one of security, performance, health',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
let limit: number | undefined;
|
||||
if (req.query.limit !== undefined && req.query.limit !== '') {
|
||||
limit = Number(req.query.limit);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new AppError(
|
||||
'Invalid limit parameter: must be a positive integer',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let offset: number | undefined;
|
||||
if (req.query.offset !== undefined && req.query.offset !== '') {
|
||||
offset = Number(req.query.offset);
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new AppError(
|
||||
'Invalid offset parameter: must be a non-negative integer',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await advisorService.getLatestScanIssues({
|
||||
severity,
|
||||
category,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get advisor issues error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all suppressions (the Ignored view).
|
||||
* GET /api/advisor/suppressions
|
||||
*/
|
||||
router.get(
|
||||
'/suppressions',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const suppressions = await advisorService.listSuppressions();
|
||||
successResponse(res, { suppressions });
|
||||
} catch (error: unknown) {
|
||||
logger.warn('List advisor suppressions error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Suppress a finding (instance fingerprint) or a whole rule.
|
||||
* POST /api/advisor/suppressions
|
||||
*/
|
||||
router.post(
|
||||
'/suppressions',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { ruleId, affectedObject, scope, reason, note } = req.body ?? {};
|
||||
const trimmedRuleId = typeof ruleId === 'string' ? ruleId.trim() : '';
|
||||
if (trimmedRuleId.length === 0 || trimmedRuleId.length > 200) {
|
||||
throw new AppError(
|
||||
'Invalid ruleId: must be a non-empty string',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
if (!SUPPRESSION_SCOPES.includes(scope)) {
|
||||
throw new AppError(
|
||||
'Invalid scope: must be one of instance, rule',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
if (!SUPPRESSION_REASONS.includes(reason)) {
|
||||
throw new AppError(
|
||||
`Invalid reason: must be one of ${SUPPRESSION_REASONS.join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
// Reject whitespace-only, but store the value verbatim: advisor findings
|
||||
// build affected_object straight from catalog identifiers without
|
||||
// trimming, so a stored-trimmed value would never match on re-scan.
|
||||
const affectedObjectStr = typeof affectedObject === 'string' ? affectedObject : '';
|
||||
if (
|
||||
scope === 'instance' &&
|
||||
(affectedObjectStr.trim().length === 0 || affectedObjectStr.length > 500)
|
||||
) {
|
||||
throw new AppError(
|
||||
'Invalid affectedObject: required for instance scope, at most 500 characters',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
if (note !== undefined && note !== null && (typeof note !== 'string' || note.length > 1000)) {
|
||||
throw new AppError(
|
||||
'Invalid note: must be a string of at most 1000 characters',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
if (reason === 'other' && (typeof note !== 'string' || note.trim().length === 0)) {
|
||||
throw new AppError(
|
||||
'Invalid note: required when reason is "other"',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const suppression = await advisorService.createSuppression({
|
||||
ruleId: trimmedRuleId,
|
||||
affectedObject: scope === 'instance' ? affectedObjectStr : null,
|
||||
scope,
|
||||
reason,
|
||||
note: note ?? null,
|
||||
createdBy: req.user?.id ?? null,
|
||||
});
|
||||
successResponse(res, suppression, 201);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Create advisor suppression error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Restore (un-suppress).
|
||||
* DELETE /api/advisor/suppressions/:id
|
||||
*/
|
||||
router.delete(
|
||||
'/suppressions/:id',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(req.params.id)) {
|
||||
throw new AppError('Invalid id: must be a valid UUID', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const deleted = await advisorService.deleteSuppression(req.params.id);
|
||||
if (!deleted) {
|
||||
throw new AppError('Suppression not found', 404, ERROR_CODES.NOT_FOUND);
|
||||
}
|
||||
successResponse(res, { deleted: true });
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Delete advisor suppression error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as advisorRouter };
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { ChatCompletionService } from '@/services/ai/chat-completion.service.js';
|
||||
import { AuthRequest, verifyAdmin, verifyUser } from '../../middlewares/auth.js';
|
||||
import { ImageGenerationService } from '@/services/ai/image-generation.service.js';
|
||||
import { EmbeddingService } from '@/services/ai/embedding.service.js';
|
||||
import { AIModelService } from '@/services/ai/ai-model.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { errorResponse, successResponse } from '@/utils/response.js';
|
||||
import { OpenRouterProvider } from '@/providers/ai/openrouter.provider.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
chatCompletionRequestSchema,
|
||||
embeddingsRequestSchema,
|
||||
imageGenerationRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const chatService = ChatCompletionService.getInstance();
|
||||
type AIProvider = 'openrouter';
|
||||
|
||||
/**
|
||||
* GET /api/ai/models
|
||||
* Get all available AI models in ListModelsResponse format
|
||||
*/
|
||||
router.get('/models', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const models = await AIModelService.getModels();
|
||||
successResponse(res, models);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai/overview
|
||||
* Get key-level Model Gateway observability from OpenRouter.
|
||||
*/
|
||||
router.get(
|
||||
'/overview',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const openRouterProvider = OpenRouterProvider.getInstance();
|
||||
const overview = await openRouterProvider.getOverview();
|
||||
successResponse(res, overview);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/ai/:provider/api-key
|
||||
* Get the active provider API key for Model Gateway display/copy.
|
||||
*/
|
||||
router.get(
|
||||
'/:provider/api-key',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const provider = parseAIProvider(req.params.provider);
|
||||
const openRouterProvider = OpenRouterProvider.getInstance();
|
||||
const key = await getProviderApiKey(provider, openRouterProvider);
|
||||
successResponse(res, key);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === ERROR_CODES.AI_INVALID_API_KEY) {
|
||||
errorResponse(
|
||||
res,
|
||||
ERROR_CODES.AI_INVALID_API_KEY,
|
||||
'OpenRouter API key is not configured.',
|
||||
400,
|
||||
'Set OPENROUTER_API_KEY in the backend environment.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/ai/:provider/api-key/rotate
|
||||
* Rotate the active provider API key for cloud-managed Model Gateway credentials.
|
||||
*/
|
||||
router.post(
|
||||
'/:provider/api-key/rotate',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const provider = parseAIProvider(req.params.provider);
|
||||
const openRouterProvider = OpenRouterProvider.getInstance();
|
||||
const key = await rotateProviderApiKey(provider, openRouterProvider);
|
||||
successResponse(res, key);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function parseAIProvider(value: string | undefined): AIProvider {
|
||||
if (value === 'openrouter') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
`Unsupported AI provider: ${value || 'unknown'}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderApiKey(provider: AIProvider, openRouterProvider: OpenRouterProvider) {
|
||||
switch (provider) {
|
||||
case 'openrouter':
|
||||
return openRouterProvider.getMaskedApiKey();
|
||||
default: {
|
||||
const exhaustiveProvider: never = provider;
|
||||
throw new AppError(
|
||||
`Unsupported AI provider: ${exhaustiveProvider}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProviderApiKey(provider: AIProvider, openRouterProvider: OpenRouterProvider) {
|
||||
switch (provider) {
|
||||
case 'openrouter':
|
||||
return openRouterProvider.rotateManagedApiKey();
|
||||
default: {
|
||||
const exhaustiveProvider: never = provider;
|
||||
throw new AppError(
|
||||
`Unsupported AI provider: ${exhaustiveProvider}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat/completion
|
||||
* Send a chat message to any supported model
|
||||
*/
|
||||
router.post(
|
||||
'/chat/completion',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = chatCompletionRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${validationResult.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { stream, messages, ...options } = validationResult.data;
|
||||
|
||||
// Handle streaming requests
|
||||
if (stream) {
|
||||
// Now we know the model is valid, set headers for SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Create and process the stream
|
||||
try {
|
||||
const streamGenerator = chatService.streamChat(messages, options);
|
||||
|
||||
for await (const data of streamGenerator) {
|
||||
if (data.chunk) {
|
||||
res.write(`data: ${JSON.stringify({ chunk: data.chunk })}\n\n`);
|
||||
}
|
||||
if (data.tokenUsage) {
|
||||
res.write(`data: ${JSON.stringify({ tokenUsage: data.tokenUsage })}\n\n`);
|
||||
}
|
||||
if (data.tool_calls) {
|
||||
res.write(`data: ${JSON.stringify({ tool_calls: data.tool_calls })}\n\n`);
|
||||
}
|
||||
if (data.annotations) {
|
||||
res.write(`data: ${JSON.stringify({ annotations: data.annotations })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion signal
|
||||
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
|
||||
} catch (streamError) {
|
||||
// If error occurs during streaming, send it in SSE format
|
||||
logger.error('Stream error during chat completion', {
|
||||
error: streamError instanceof Error ? streamError.message : String(streamError),
|
||||
stack: streamError instanceof Error ? streamError.stack : undefined,
|
||||
});
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ error: true, message: streamError instanceof Error ? streamError.message : String(streamError) })}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-streaming requests
|
||||
const result = await chatService.chat(messages, options);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to generate chat',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/ai/image/generation
|
||||
* Generate images using specified model
|
||||
*/
|
||||
router.post(
|
||||
'/image/generation',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = imageGenerationRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${validationResult.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ImageGenerationService.generate(validationResult.data);
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to generate image',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/ai/embeddings
|
||||
* Generate embeddings for text input
|
||||
*/
|
||||
router.post(
|
||||
'/embeddings',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = embeddingsRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${validationResult.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const embeddingService = EmbeddingService.getInstance();
|
||||
const result = await embeddingService.createEmbeddings(validationResult.data);
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new AppError(
|
||||
error instanceof Error ? error.message : 'Failed to generate embeddings',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as aiRouter };
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
posthogTimeframeSchema,
|
||||
posthogBreakdownSchema,
|
||||
posthogMetricSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { verifyUser, verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { AnalyticsService } from '@/services/analytics/analytics.service.js';
|
||||
|
||||
export const analyticsRouter = Router();
|
||||
const service = AnalyticsService.getInstance();
|
||||
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
function parseLimit(raw: unknown): number {
|
||||
const n = parseInt(String(raw ?? '10'), 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return 10;
|
||||
}
|
||||
return Math.min(n, MAX_LIMIT);
|
||||
}
|
||||
|
||||
// GET /api/analytics/connection
|
||||
analyticsRouter.get(
|
||||
'/connection',
|
||||
verifyUser,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const conn = await service.getConnection();
|
||||
if (!conn) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json({ connected: true, connection: conn });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/dashboards
|
||||
analyticsRouter.get(
|
||||
'/dashboards',
|
||||
verifyUser,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getDashboards();
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/summary
|
||||
analyticsRouter.get(
|
||||
'/summary',
|
||||
verifyUser,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getSummary();
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/events
|
||||
analyticsRouter.get(
|
||||
'/events',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getRecentEvents(parseLimit(req.query.limit));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/analytics/connection
|
||||
analyticsRouter.delete(
|
||||
'/connection',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await service.disconnect();
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// v2.5 analytics dashboard endpoints — proxy to cloud-backend, which talks to
|
||||
// PostHog. Auth/auth checks remain on this side via verifyUser; project
|
||||
// authority comes from the project JWT signed by PostHogProvider.
|
||||
|
||||
// GET /api/analytics/web-overview?timeframe=7d
|
||||
analyticsRouter.get(
|
||||
'/web-overview',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const timeframe = posthogTimeframeSchema.safeParse(req.query.timeframe ?? '7d');
|
||||
if (!timeframe.success) {
|
||||
throw new AppError('Invalid timeframe', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const data = await service.getWebOverview(timeframe.data);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/web-stats?breakdown=Page&timeframe=7d
|
||||
analyticsRouter.get(
|
||||
'/web-stats',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const breakdown = posthogBreakdownSchema.safeParse(req.query.breakdown);
|
||||
if (!breakdown.success) {
|
||||
throw new AppError('Invalid breakdown', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const timeframe = posthogTimeframeSchema.safeParse(req.query.timeframe ?? '7d');
|
||||
if (!timeframe.success) {
|
||||
throw new AppError('Invalid timeframe', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const data = await service.getWebStats(breakdown.data, timeframe.data);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/trends?metric=views&timeframe=7d
|
||||
analyticsRouter.get(
|
||||
'/trends',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const metric = posthogMetricSchema.safeParse(req.query.metric);
|
||||
if (!metric.success) {
|
||||
throw new AppError('Invalid metric', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const timeframe = posthogTimeframeSchema.safeParse(req.query.timeframe ?? '7d');
|
||||
if (!timeframe.success) {
|
||||
throw new AppError('Invalid timeframe', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const data = await service.getTrends(metric.data, timeframe.data);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/retention
|
||||
// Decoupled from page-level timeframe per design — always Week/8.
|
||||
analyticsRouter.get(
|
||||
'/retention',
|
||||
verifyUser,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getRetention();
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/analytics/recordings?limit=10
|
||||
analyticsRouter.get(
|
||||
'/recordings',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getRecordings(parseLimit(req.query.limit));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/analytics/recordings/:id/share
|
||||
analyticsRouter.post(
|
||||
'/recordings/:id/share',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const recordingId = String(req.params.id || '');
|
||||
const data = await service.createRecordingShare(recordingId);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { AuthService } from '@/services/auth/auth.service.js';
|
||||
import { AuthRequest, verifyToken } from '@/api/middlewares/auth.js';
|
||||
import { TokenManager } from '@/infra/security/token.manager.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import {
|
||||
ADMIN_REFRESH_TOKEN_COOKIE_NAME,
|
||||
setAdminRefreshTokenCookie,
|
||||
clearAdminRefreshTokenCookie,
|
||||
} from '@/utils/cookies.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createAdminSessionRequestSchema,
|
||||
exchangeAdminSessionRequestSchema,
|
||||
type CreateAdminSessionResponse,
|
||||
type GetCurrentAdminSessionResponse,
|
||||
} from '@insforge/shared-schemas';
|
||||
import logger from '@/utils/logger.js';
|
||||
|
||||
const router = Router();
|
||||
const authService = AuthService.getInstance();
|
||||
|
||||
// POST /api/auth/admin/sessions/exchange - Exchange authorization code for admin session
|
||||
router.post('/sessions/exchange', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = exchangeAdminSessionRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { code } = validationResult.data;
|
||||
const result: CreateAdminSessionResponse =
|
||||
await authService.adminLoginWithAuthorizationCode(code);
|
||||
|
||||
// Set refresh token as httpOnly cookie + CSRF token for web clients
|
||||
const tokenManager = TokenManager.getInstance();
|
||||
const { refreshToken, csrfToken } = tokenManager.generateRefreshTokenWithCsrf(
|
||||
result.admin.sub,
|
||||
'admin'
|
||||
);
|
||||
setAdminRefreshTokenCookie(res, refreshToken);
|
||||
|
||||
successResponse(res, { ...result, csrfToken });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
} else {
|
||||
logger.error('[Auth:AdminSessionExchange] Failed to exchange admin session', { error });
|
||||
next(new AppError('Failed to exchange admin session', 500, ERROR_CODES.INTERNAL_ERROR));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/sessions - Create admin session (web only)
|
||||
router.post('/sessions', (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = createAdminSessionRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = validationResult.data;
|
||||
const result: CreateAdminSessionResponse = authService.adminLogin(username, password);
|
||||
|
||||
// Set refresh token as httpOnly cookie + CSRF token for web clients
|
||||
const tokenManager = TokenManager.getInstance();
|
||||
const { refreshToken, csrfToken } = tokenManager.generateRefreshTokenWithCsrf(
|
||||
result.admin.sub,
|
||||
'admin'
|
||||
);
|
||||
setAdminRefreshTokenCookie(res, refreshToken);
|
||||
|
||||
successResponse(res, { ...result, csrfToken });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/admin/sessions/current - Get current dashboard admin session
|
||||
router.get(
|
||||
'/sessions/current',
|
||||
verifyToken,
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (req.user?.role !== 'project_admin' || !req.user.id) {
|
||||
throw new AppError('Admin access required', 403, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const response: GetCurrentAdminSessionResponse = {
|
||||
admin: {
|
||||
sub: req.user.id,
|
||||
},
|
||||
};
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/admin/refresh - Refresh admin dashboard access token
|
||||
// Uses a dashboard-specific httpOnly cookie + X-CSRF-Token header.
|
||||
router.post('/refresh', (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tokenManager = TokenManager.getInstance();
|
||||
const refreshToken = req.cookies?.[ADMIN_REFRESH_TOKEN_COOKIE_NAME];
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new AppError('No admin refresh token provided', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const payload = tokenManager.verifyRefreshToken(refreshToken);
|
||||
if (payload.sessionType !== 'admin') {
|
||||
throw new AppError('Invalid admin refresh session type', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const csrfHeader = req.headers['x-csrf-token'] as string | undefined;
|
||||
if (!tokenManager.verifyCsrfToken(csrfHeader, payload)) {
|
||||
logger.warn('[Auth:AdminRefresh] CSRF token validation failed');
|
||||
throw new AppError('Invalid CSRF token', 403, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const newAccessToken = tokenManager.generateAccessToken({
|
||||
sub: payload.sub,
|
||||
role: 'project_admin',
|
||||
});
|
||||
const { refreshToken: newRefreshToken, csrfToken: newCsrfToken } =
|
||||
tokenManager.generateRefreshTokenWithCsrf(payload.sub, 'admin', payload.csrfNonce);
|
||||
setAdminRefreshTokenCookie(res, newRefreshToken);
|
||||
|
||||
successResponse(res, {
|
||||
admin: {
|
||||
sub: payload.sub,
|
||||
},
|
||||
accessToken: newAccessToken,
|
||||
csrfToken: newCsrfToken,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.statusCode === 401) {
|
||||
clearAdminRefreshTokenCookie(res);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/logout - Logout dashboard session
|
||||
router.post('/logout', (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
clearAdminRefreshTokenCookie(res);
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: 'Logged out successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,348 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AuthRequest, verifyAdmin } from '@/api/middlewares/auth.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { AuthService } from '@/services/auth/auth.service.js';
|
||||
import { OAuthPKCEService } from '@/services/auth/oauth-pkce.service.js';
|
||||
import { AuthConfigService } from '@/services/auth/auth-config.service.js';
|
||||
import { CustomOAuthConfigService } from '@/services/auth/custom-oauth-config.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { CustomOAuthProvider } from '@/providers/oauth/custom.provider.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createCustomOAuthConfigRequestSchema,
|
||||
updateCustomOAuthConfigRequestSchema,
|
||||
listCustomOAuthConfigsResponseSchema,
|
||||
oAuthInitRequestSchema,
|
||||
customOAuthKeySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const authService = AuthService.getInstance();
|
||||
const authConfigService = AuthConfigService.getInstance();
|
||||
const oAuthPKCEService = OAuthPKCEService.getInstance();
|
||||
const customOAuthConfigService = CustomOAuthConfigService.getInstance();
|
||||
const customOAuthProvider = CustomOAuthProvider.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
const validateJwtSecret = (): string => {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret || jwtSecret.trim() === '') {
|
||||
throw new AppError(
|
||||
'JWT_SECRET environment variable is not configured.',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
return jwtSecret;
|
||||
};
|
||||
|
||||
// ── Admin CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/configs', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configs = await customOAuthConfigService.listConfigs();
|
||||
const payload = { data: configs, count: configs.length };
|
||||
const parsed = listCustomOAuthConfigsResponseSchema.parse(payload);
|
||||
successResponse(res, parsed);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list custom OAuth configs', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:key/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keyValidation = customOAuthKeySchema.safeParse(req.params.key);
|
||||
if (!keyValidation.success) {
|
||||
throw new AppError('Invalid custom OAuth key', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const key = keyValidation.data;
|
||||
|
||||
const config = await customOAuthConfigService.getConfigByKey(key);
|
||||
if (!config) {
|
||||
throw new AppError(
|
||||
`Custom OAuth configuration for ${key} not found`,
|
||||
404,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
const clientSecret = await customOAuthConfigService.getClientSecretByKey(key);
|
||||
successResponse(res, {
|
||||
...config,
|
||||
clientSecret: clientSecret || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get custom OAuth config', { error, key: req.params.key });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/configs',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = createCustomOAuthConfigRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues
|
||||
.map(
|
||||
(e: { path: (string | number)[]; message: string }) =>
|
||||
`${e.path.join('.')}: ${e.message}`
|
||||
)
|
||||
.join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const config = await customOAuthConfigService.createConfig(validationResult.data);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_CUSTOM_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: {
|
||||
key: config.key,
|
||||
name: config.name,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create custom OAuth config', { error });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:key/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keyValidation = customOAuthKeySchema.safeParse(req.params.key);
|
||||
if (!keyValidation.success) {
|
||||
throw new AppError('Invalid custom OAuth key', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const key = keyValidation.data;
|
||||
|
||||
const validationResult = updateCustomOAuthConfigRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues
|
||||
.map(
|
||||
(e: { path: (string | number)[]; message: string }) =>
|
||||
`${e.path.join('.')}: ${e.message}`
|
||||
)
|
||||
.join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const config = await customOAuthConfigService.updateConfig(key, validationResult.data);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_CUSTOM_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: {
|
||||
key,
|
||||
updatedFields: Object.keys(validationResult.data),
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update custom OAuth config', { error, key: req.params.key });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:key/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keyValidation = customOAuthKeySchema.safeParse(req.params.key);
|
||||
if (!keyValidation.success) {
|
||||
throw new AppError('Invalid custom OAuth key', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const deleted = await customOAuthConfigService.deleteConfig(keyValidation.data);
|
||||
if (!deleted) {
|
||||
throw new AppError(
|
||||
`Custom OAuth configuration for ${req.params.key} not found`,
|
||||
404,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_CUSTOM_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: {
|
||||
key: keyValidation.data,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `Custom OAuth configuration for ${req.params.key} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete custom OAuth config', { error, key: req.params.key });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Public OAuth flow (init + callback) ─────────────────────────────────
|
||||
|
||||
router.get('/:key', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keyValidation = customOAuthKeySchema.safeParse(req.params.key);
|
||||
if (!keyValidation.success) {
|
||||
throw new AppError('Invalid custom OAuth key', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const key = keyValidation.data;
|
||||
|
||||
const queryValidation = oAuthInitRequestSchema.safeParse(req.query);
|
||||
if (!queryValidation.success) {
|
||||
throw new AppError(
|
||||
queryValidation.error.issues
|
||||
.map(
|
||||
(e: { path: (string | number)[]; message: string }) =>
|
||||
`${e.path.join('.')}: ${e.message}`
|
||||
)
|
||||
.join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { redirect_uri, code_challenge, ...additionalParams } = queryValidation.data;
|
||||
const redirectUri = redirect_uri;
|
||||
|
||||
if (!(await authConfigService.validateRedirectUrl(redirectUri))) {
|
||||
throw new AppError(
|
||||
`${redirectUri} is not in the allowed redirect URLs`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please add this URL to the allowed redirect URLs in the authentication configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
const state = jwt.sign(
|
||||
{
|
||||
provider: key,
|
||||
redirectUri,
|
||||
codeChallenge: code_challenge,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
validateJwtSecret(),
|
||||
{ algorithm: 'HS256', expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const authUrl = await customOAuthProvider.generateOAuthUrl(key, state, additionalParams);
|
||||
successResponse(res, { authUrl });
|
||||
} catch (error) {
|
||||
logger.error('Custom OAuth init failed', { error, key: req.params.key });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:key/callback', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keyValidation = customOAuthKeySchema.safeParse(req.params.key);
|
||||
if (!keyValidation.success) {
|
||||
throw new AppError('Invalid custom OAuth key', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const key = keyValidation.data;
|
||||
const code = req.query.code as string | undefined;
|
||||
const state = req.query.state as string | undefined;
|
||||
if (!code || !state) {
|
||||
throw new AppError('code and state are required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const stateData = jwt.verify(state, validateJwtSecret()) as {
|
||||
provider: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
};
|
||||
if (stateData.provider !== key) {
|
||||
throw new AppError(
|
||||
'Provider mismatch between callback path and state',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
if (!stateData.redirectUri) {
|
||||
throw new AppError('redirectUri is required in state', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
if (!(await authConfigService.validateRedirectUrl(stateData.redirectUri))) {
|
||||
throw new AppError(
|
||||
`${stateData.redirectUri} is not in the allowed redirect URLs`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please add this URL to the allowed redirect URLs in the authentication configuration.'
|
||||
);
|
||||
}
|
||||
if (!stateData.codeChallenge) {
|
||||
throw new AppError('code_challenge is required in state', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const oauthUser = await customOAuthProvider.handleCallback(key, code, state);
|
||||
const session = await authService.findOrCreateThirdPartyUser(
|
||||
oauthUser.provider,
|
||||
oauthUser.providerId,
|
||||
oauthUser.email,
|
||||
oauthUser.userName,
|
||||
oauthUser.avatarUrl,
|
||||
oauthUser.identityData
|
||||
);
|
||||
|
||||
const exchangeCode = oAuthPKCEService.createCode({
|
||||
userId: session.user.id,
|
||||
codeChallenge: stateData.codeChallenge,
|
||||
provider: key,
|
||||
});
|
||||
|
||||
const successUrl = new URL(stateData.redirectUri);
|
||||
successUrl.searchParams.set('insforge_code', exchangeCode);
|
||||
return res.redirect(successUrl.toString());
|
||||
} catch (error) {
|
||||
logger.error('Custom OAuth callback failed', {
|
||||
key: req.params.key,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
if (req.query.state) {
|
||||
try {
|
||||
const stateData = jwt.verify(req.query.state as string, validateJwtSecret()) as {
|
||||
redirectUri?: string;
|
||||
};
|
||||
if (
|
||||
stateData.redirectUri &&
|
||||
(await authConfigService.validateRedirectUrl(stateData.redirectUri))
|
||||
) {
|
||||
const errorUrl = new URL(stateData.redirectUri);
|
||||
errorUrl.searchParams.set('error', 'Authentication failed');
|
||||
return res.redirect(errorUrl.toString());
|
||||
}
|
||||
} catch {
|
||||
// Ignore redirect fallback failures
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { AuthService } from '@/services/auth/auth.service.js';
|
||||
import { OAuthConfigService } from '@/services/auth/oauth-config.service.js';
|
||||
import { AuthConfigService } from '@/services/auth/auth-config.service.js';
|
||||
import { OAuthPKCEService } from '@/services/auth/oauth-pkce.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { TokenManager } from '@/infra/security/token.manager.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AuthRequest, verifyAdmin } from '@/api/middlewares/auth.js';
|
||||
import { setRefreshTokenCookie } from '@/utils/cookies.js';
|
||||
import { parseClientType } from '@/utils/utils.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createOAuthConfigRequestSchema,
|
||||
updateOAuthConfigRequestSchema,
|
||||
oAuthInitRequestSchema,
|
||||
oAuthCodeExchangeRequestSchema,
|
||||
type ListOAuthConfigsResponse,
|
||||
oAuthProvidersSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { isOAuthSharedKeysAvailable } from '@/utils/environment.js';
|
||||
|
||||
const router = Router();
|
||||
const authService = AuthService.getInstance();
|
||||
const authConfigService = AuthConfigService.getInstance();
|
||||
const oAuthConfigService = OAuthConfigService.getInstance();
|
||||
const oAuthPKCEService = OAuthPKCEService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
// Helper function to validate JWT_SECRET
|
||||
const validateJwtSecret = (): string => {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret || jwtSecret.trim() === '') {
|
||||
throw new AppError(
|
||||
'JWT_SECRET environment variable is not configured.',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
return jwtSecret;
|
||||
};
|
||||
|
||||
// OAuth Configuration Management Routes (must come before wildcard routes)
|
||||
// GET /api/auth/oauth/configs - List all OAuth configurations (admin only)
|
||||
router.get('/configs', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configs = await oAuthConfigService.getAllConfigs();
|
||||
const response: ListOAuthConfigsResponse = {
|
||||
data: configs,
|
||||
count: configs.length,
|
||||
};
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list OAuth configurations', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/oauth/:provider/config - Get specific OAuth configuration (admin only)
|
||||
router.get(
|
||||
'/:provider/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
const config = await oAuthConfigService.getConfigByProvider(provider);
|
||||
const clientSecret = await oAuthConfigService.getClientSecretByProvider(provider);
|
||||
|
||||
if (!config) {
|
||||
throw new AppError(
|
||||
`OAuth configuration for ${provider} not found`,
|
||||
404,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
successResponse(res, {
|
||||
...config,
|
||||
clientSecret: clientSecret || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get OAuth config by provider', {
|
||||
provider: req.params.provider,
|
||||
error,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/oauth/configs - Create new OAuth configuration (admin only)
|
||||
router.post(
|
||||
'/configs',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = createOAuthConfigRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const input = validationResult.data;
|
||||
|
||||
// Check if using shared keys when not allowed
|
||||
if (input.useSharedKey && !isOAuthSharedKeysAvailable()) {
|
||||
throw new AppError(
|
||||
'Shared OAuth keys are not enabled in this environment',
|
||||
400,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const config = await oAuthConfigService.createConfig(input);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: {
|
||||
provider: input.provider,
|
||||
useSharedKey: input.useSharedKey || false,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create OAuth configuration', { error });
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/auth/oauth/:provider/config - Update OAuth configuration (admin only)
|
||||
router.put(
|
||||
'/:provider/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const provider = req.params.provider;
|
||||
if (!provider || provider.length === 0 || provider.length > 50) {
|
||||
throw new AppError('Invalid provider name', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const validationResult = updateOAuthConfigRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const input = validationResult.data;
|
||||
|
||||
// Check if using shared keys when not allowed
|
||||
if (input.useSharedKey && !isOAuthSharedKeysAvailable()) {
|
||||
throw new AppError(
|
||||
'Shared OAuth keys are not enabled in this environment',
|
||||
400,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const config = await oAuthConfigService.updateConfig(provider, input);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: {
|
||||
provider,
|
||||
updatedFields: Object.keys(input),
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update OAuth configuration', {
|
||||
error,
|
||||
provider: req.params.provider,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/auth/oauth/:provider/config - Delete OAuth configuration (admin only)
|
||||
router.delete(
|
||||
'/:provider/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const provider = req.params.provider;
|
||||
if (!provider || provider.length === 0 || provider.length > 50) {
|
||||
throw new AppError('Invalid provider name', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const deleted = await oAuthConfigService.deleteConfig(provider);
|
||||
|
||||
if (!deleted) {
|
||||
throw new AppError(
|
||||
`OAuth configuration for ${provider} not found`,
|
||||
404,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_OAUTH_CONFIG',
|
||||
module: 'AUTH',
|
||||
details: { provider },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `OAuth configuration for ${provider} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete OAuth configuration', {
|
||||
error,
|
||||
provider: req.params.provider,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// OAuth Flow Routes
|
||||
// GET /api/auth/oauth/:provider - Initialize OAuth flow for any supported provider
|
||||
router.get('/:provider', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
|
||||
// Validate provider using OAuthProvidersSchema
|
||||
const providerValidation = oAuthProvidersSchema.safeParse(provider);
|
||||
if (!providerValidation.success) {
|
||||
throw new AppError(
|
||||
`Unsupported OAuth provider: ${provider}. Supported providers: ${oAuthProvidersSchema.options.join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
// Validate query params (PKCE code_challenge per RFC 7636)
|
||||
const queryValidation = oAuthInitRequestSchema.safeParse(req.query);
|
||||
if (!queryValidation.success) {
|
||||
throw new AppError(
|
||||
queryValidation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { redirect_uri, code_challenge, ...additionalParams } = queryValidation.data;
|
||||
const validatedProvider = providerValidation.data;
|
||||
const redirectUri = redirect_uri;
|
||||
|
||||
if (!(await authConfigService.validateRedirectUrl(redirectUri))) {
|
||||
throw new AppError(
|
||||
`${redirectUri} is not in the allowed redirect URLs`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please add this URL to the allowed redirect URLs in the authentication configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
const jwtPayload = {
|
||||
provider: validatedProvider,
|
||||
redirectUri,
|
||||
codeChallenge: code_challenge,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const jwtSecret = validateJwtSecret();
|
||||
const state = jwt.sign(jwtPayload, jwtSecret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '1h', // Set expiration time for the state token
|
||||
});
|
||||
|
||||
const authUrl = await authService.generateOAuthUrl(validatedProvider, state, additionalParams);
|
||||
successResponse(res, { authUrl });
|
||||
} catch (error) {
|
||||
logger.error(`${req.params.provider} OAuth error`, { error });
|
||||
|
||||
// If it's already an AppError, pass it through
|
||||
if (error instanceof AppError) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, return the generic OAuth configuration error
|
||||
next(
|
||||
new AppError(
|
||||
`${req.params.provider} OAuth is not properly configured. Please check your oauth configurations.`,
|
||||
500,
|
||||
ERROR_CODES.AUTH_OAUTH_CONFIG_ERROR
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/oauth/shared/callback/:state - Shared callback for OAuth providers
|
||||
router.get('/shared/callback/:state', async (req: Request, res: Response, next: NextFunction) => {
|
||||
let redirectUri: string | undefined;
|
||||
|
||||
try {
|
||||
const { state } = req.params;
|
||||
const { success, error, payload } = req.query;
|
||||
|
||||
if (!state) {
|
||||
logger.warn('Shared OAuth callback called without state parameter');
|
||||
throw new AppError('State parameter is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
let provider: string;
|
||||
let codeChallenge: string;
|
||||
try {
|
||||
const jwtSecret = validateJwtSecret();
|
||||
const decodedState = jwt.verify(state, jwtSecret) as {
|
||||
provider: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
};
|
||||
redirectUri = decodedState.redirectUri || '';
|
||||
provider = decodedState.provider || '';
|
||||
codeChallenge = decodedState.codeChallenge || '';
|
||||
} catch {
|
||||
logger.warn('Invalid state parameter', { state });
|
||||
throw new AppError('Invalid state parameter', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Validate provider using OAuthProvidersSchema
|
||||
const providerValidation = oAuthProvidersSchema.safeParse(provider);
|
||||
if (!providerValidation.success) {
|
||||
logger.warn('Invalid provider in state', { provider });
|
||||
throw new AppError(
|
||||
`Invalid provider in state: ${provider}. Supported providers: ${oAuthProvidersSchema.options.join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const validatedProvider = providerValidation.data;
|
||||
if (!redirectUri) {
|
||||
throw new AppError('redirectUri is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
if (redirectUri && !(await authConfigService.validateRedirectUrl(redirectUri))) {
|
||||
logger.warn('Redirect URI is not in allowed redirect URLs in shared callback', {
|
||||
redirectUri,
|
||||
});
|
||||
throw new AppError(
|
||||
`${redirectUri} is not in the allowed redirect URLs`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please add this URL to the allowed redirect URLs in the authentication configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!codeChallenge) {
|
||||
throw new AppError('code_challenge is required in state', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
if (success !== 'true') {
|
||||
const errorMessage = error || 'OAuth Authentication Failed';
|
||||
logger.warn('Shared OAuth callback failed', { error: errorMessage, provider });
|
||||
const errorUrl = new URL(redirectUri);
|
||||
errorUrl.searchParams.set('error', String(errorMessage));
|
||||
return res.redirect(errorUrl.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
if (!payload) {
|
||||
throw new AppError('No payload provided in callback', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const payloadData = JSON.parse(
|
||||
Buffer.from(payload as string, 'base64').toString('utf8')
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// Handle shared callback - transforms payload and creates/finds user
|
||||
const result = await authService.handleSharedCallback(validatedProvider, payloadData);
|
||||
|
||||
// Create exchange code for PKCE flow (instead of exposing tokens in URL)
|
||||
// Only store minimal data - user and token are fetched fresh on exchange
|
||||
const exchangeCode = oAuthPKCEService.createCode({
|
||||
userId: result.user.id,
|
||||
codeChallenge,
|
||||
provider: validatedProvider,
|
||||
});
|
||||
|
||||
// Redirect with only the exchange code (no sensitive tokens in URL)
|
||||
const successUrl = new URL(redirectUri);
|
||||
successUrl.searchParams.set('insforge_code', exchangeCode);
|
||||
return res.redirect(successUrl.toString());
|
||||
} catch (error) {
|
||||
logger.error('Shared OAuth callback completion error', {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
provider: validatedProvider,
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'OAuth Authentication Failed';
|
||||
const errorUrl = new URL(redirectUri);
|
||||
errorUrl.searchParams.set('error', errorMessage);
|
||||
return res.redirect(errorUrl.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Shared OAuth callback error', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle OAuth provider callback (shared logic for GET and POST)
|
||||
* Most providers use GET, but Apple uses POST with form data
|
||||
*/
|
||||
const handleOAuthCallback = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
// Support both query params (GET) and body params (POST for Apple)
|
||||
// Use method-based source selection to prevent parameter pollution attacks
|
||||
const isPostRequest = req.method === 'POST';
|
||||
const code = isPostRequest ? (req.body.code as string) : (req.query.code as string);
|
||||
const state = isPostRequest ? (req.body.state as string) : (req.query.state as string);
|
||||
const token = isPostRequest ? (req.body.id_token as string) : (req.query.token as string);
|
||||
|
||||
if (!state) {
|
||||
logger.warn('OAuth callback called without state parameter');
|
||||
throw new AppError('State parameter is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Decode state data (needed for both success and error paths)
|
||||
let redirectUri: string;
|
||||
let codeChallenge: string;
|
||||
|
||||
try {
|
||||
const jwtSecret = validateJwtSecret();
|
||||
const stateData = jwt.verify(state, jwtSecret) as {
|
||||
provider: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
};
|
||||
redirectUri = stateData.redirectUri || '';
|
||||
codeChallenge = stateData.codeChallenge || '';
|
||||
} catch {
|
||||
// Invalid state
|
||||
logger.warn('Invalid state in provider callback', { state });
|
||||
throw new AppError('Invalid state parameter', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
if (!redirectUri) {
|
||||
throw new AppError('redirectUri is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
if (!(await authConfigService.validateRedirectUrl(redirectUri))) {
|
||||
logger.warn('Redirect URI is not in allowed redirect URLs in callback', { redirectUri });
|
||||
throw new AppError(
|
||||
`${redirectUri} is not in the allowed redirect URLs`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please add this URL to the allowed redirect URLs in the authentication configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!codeChallenge) {
|
||||
throw new AppError('code_challenge is required in state', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate provider using OAuthProvidersSchema
|
||||
const providerValidation = oAuthProvidersSchema.safeParse(provider);
|
||||
if (!providerValidation.success) {
|
||||
throw new AppError(
|
||||
`Unsupported OAuth provider: ${provider}. Supported providers: ${oAuthProvidersSchema.options.join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const validatedProvider = providerValidation.data;
|
||||
|
||||
const result = await authService.handleOAuthCallback(validatedProvider, {
|
||||
code: code || undefined,
|
||||
token: token || undefined,
|
||||
state: state || undefined,
|
||||
});
|
||||
|
||||
// Create exchange code for PKCE flow (instead of exposing tokens in URL)
|
||||
// Only store minimal data - user and token are fetched fresh on exchange
|
||||
const exchangeCode = oAuthPKCEService.createCode({
|
||||
userId: result.user.id,
|
||||
codeChallenge,
|
||||
provider: validatedProvider,
|
||||
});
|
||||
|
||||
// Redirect with only the exchange code (no sensitive tokens in URL)
|
||||
const successUrl = new URL(redirectUri);
|
||||
successUrl.searchParams.set('insforge_code', exchangeCode);
|
||||
return res.redirect(successUrl.toString());
|
||||
} catch (error) {
|
||||
logger.error('OAuth callback error', {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
provider: req.params.provider,
|
||||
hasCode: !!code,
|
||||
hasState: !!state,
|
||||
hasToken: !!token,
|
||||
});
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'OAuth Authentication Failed';
|
||||
|
||||
// Redirect with error in URL parameters
|
||||
const errorUrl = new URL(redirectUri);
|
||||
errorUrl.searchParams.set('error', errorMessage);
|
||||
return res.redirect(errorUrl.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('OAuth callback error', { error });
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/auth/oauth/:provider/callback - OAuth provider callback (most providers)
|
||||
router.get('/:provider/callback', handleOAuthCallback);
|
||||
|
||||
// POST /api/auth/oauth/:provider/callback - OAuth provider callback (Apple uses POST with form_post)
|
||||
router.post('/:provider/callback', handleOAuthCallback);
|
||||
|
||||
// POST /api/auth/oauth/exchange - Exchange OAuth code for tokens (PKCE flow)
|
||||
// Query params: client_type (optional) - 'web' (default), 'mobile', 'desktop', or 'server'
|
||||
router.post('/exchange', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const clientType = parseClientType(req.query.client_type);
|
||||
|
||||
const validationResult = oAuthCodeExchangeRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { code, code_verifier } = validationResult.data;
|
||||
const result = await oAuthPKCEService.exchangeCode(code, code_verifier);
|
||||
|
||||
const tokenManager = TokenManager.getInstance();
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.USERS },
|
||||
'system'
|
||||
);
|
||||
|
||||
if (clientType === 'web') {
|
||||
const { refreshToken, csrfToken } = tokenManager.generateRefreshTokenWithCsrf(
|
||||
result.user.id,
|
||||
'user'
|
||||
);
|
||||
setRefreshTokenCookie(res, refreshToken);
|
||||
|
||||
successResponse(res, {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
csrfToken,
|
||||
});
|
||||
} else {
|
||||
const refreshToken = tokenManager.generateRefreshToken(result.user.id, 'user');
|
||||
successResponse(res, {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
refreshToken,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('OAuth exchange error', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,382 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { computeWriteLimiter, computeLogsRateLimiter } from '@/api/middlewares/rate-limiters.js';
|
||||
import { ComputeServicesService } from '@/services/compute/services.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES, createServiceSchema, updateServiceSchema } from '@insforge/shared-schemas';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
|
||||
const router = Router();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
function getProjectId(req: AuthRequest): string {
|
||||
// Cloud: projectId is set by verifyCloudBackend from the JWT claim
|
||||
// Self-hosted: fall back to the server-level PROJECT_ID env var
|
||||
return req.projectId || process.env.PROJECT_ID || 'default';
|
||||
}
|
||||
|
||||
function bestEffortAudit(params: Parameters<typeof auditService.log>[0]) {
|
||||
auditService.log(params).catch((err) => {
|
||||
logger.error('Audit log failed (best-effort)', { error: err });
|
||||
});
|
||||
}
|
||||
|
||||
function bestEffortBroadcast() {
|
||||
try {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.COMPUTE_SERVICES },
|
||||
'system'
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Socket broadcast failed (best-effort)', { error: err });
|
||||
}
|
||||
}
|
||||
|
||||
// List services
|
||||
router.get('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const services = await svc.listServices(getProjectId(req));
|
||||
successResponse(res, services);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get service
|
||||
router.get('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const service = await svc.getService(req.params.id);
|
||||
|
||||
if (service.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
successResponse(res, service);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create service
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createServiceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please check the request body, it must conform with the CreateServiceRequest schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const projectId = getProjectId(req);
|
||||
const service = await svc.createService({ ...validation.data, projectId });
|
||||
|
||||
successResponse(res, service, 201);
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_COMPUTE_SERVICE',
|
||||
module: 'COMPUTE',
|
||||
details: { serviceName: validation.data.name, projectId },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
bestEffortBroadcast();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Prepare for deploy (create DB record + Fly app, no machine)
|
||||
router.post(
|
||||
'/deploy',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createServiceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please check the request body, it must conform with the CreateServiceRequest schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const projectId = getProjectId(req);
|
||||
const service = await svc.prepareForDeploy({ ...validation.data, projectId });
|
||||
|
||||
successResponse(res, service, 201);
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'PREPARE_COMPUTE_DEPLOY',
|
||||
module: 'COMPUTE',
|
||||
details: { serviceName: validation.data.name, projectId },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Issue a Fly deploy token for the CLI (cloud-managed mode only).
|
||||
// Used so `compute deploy` can run flyctl without the user holding
|
||||
// their own FLY_API_TOKEN.
|
||||
router.post(
|
||||
'/:id/deploy-token',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const tokenResult = await svc.issueDeployTokenForService(req.params.id);
|
||||
successResponse(res, tokenResult);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update service
|
||||
router.patch(
|
||||
'/:id',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = updateServiceSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please check the request body, it must conform with the UpdateServiceRequest schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const service = await svc.updateService(req.params.id, validation.data);
|
||||
|
||||
successResponse(res, service);
|
||||
|
||||
// Redact envVars — only log the key names, never secret values
|
||||
const auditDetails: Record<string, unknown> = {
|
||||
serviceId: req.params.id,
|
||||
changes: Object.keys(validation.data),
|
||||
};
|
||||
if ('envVars' in validation.data) {
|
||||
auditDetails.envVarsUpdated = true;
|
||||
}
|
||||
if ('envVarsPatch' in validation.data && validation.data.envVarsPatch) {
|
||||
// Log only the *keys* touched so an audit reader knows which secrets
|
||||
// rotated, never the values.
|
||||
auditDetails.envVarsPatch = {
|
||||
setKeys: Object.keys(validation.data.envVarsPatch.set ?? {}),
|
||||
unsetKeys: validation.data.envVarsPatch.unset ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_COMPUTE_SERVICE',
|
||||
module: 'COMPUTE',
|
||||
details: auditDetails,
|
||||
ip_address: req.ip,
|
||||
});
|
||||
bestEffortBroadcast();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete service
|
||||
router.delete(
|
||||
'/:id',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Returns a snapshot of the deleted row (incl. encrypted env blob) so the
|
||||
// audit log retains enough state to reconstruct the service if the delete
|
||||
// turns out to have been a mistake. Today the row + Fly app are gone the
|
||||
// moment this returns; the audit entry is the only paper trail.
|
||||
const snapshot = await svc.deleteService(req.params.id);
|
||||
|
||||
successResponse(res, { message: 'Service deleted' });
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_COMPUTE_SERVICE',
|
||||
module: 'COMPUTE',
|
||||
details: {
|
||||
serviceId: req.params.id,
|
||||
serviceName: existing.name,
|
||||
snapshot,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
bestEffortBroadcast();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Stop service
|
||||
router.post(
|
||||
'/:id/stop',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const service = await svc.stopService(req.params.id);
|
||||
|
||||
successResponse(res, service);
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'STOP_COMPUTE_SERVICE',
|
||||
module: 'COMPUTE',
|
||||
details: { serviceId: req.params.id, serviceName: existing.name },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
bestEffortBroadcast();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Start service
|
||||
router.post(
|
||||
'/:id/start',
|
||||
verifyAdmin,
|
||||
computeWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const service = await svc.startService(req.params.id);
|
||||
|
||||
successResponse(res, service);
|
||||
|
||||
bestEffortAudit({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'START_COMPUTE_SERVICE',
|
||||
module: 'COMPUTE',
|
||||
details: { serviceId: req.params.id, serviceName: existing.name },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
bestEffortBroadcast();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get service lifecycle events (start/stop/exit/restart from Fly machine events).
|
||||
// Not container stdout/stderr — that's separate roadmap work; see spec
|
||||
// 2026-04-07-compute-dashboard-ux-design.md for the rationale.
|
||||
router.get(
|
||||
'/:id/events',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const events = await svc.getServiceEvents(req.params.id, { limit });
|
||||
|
||||
successResponse(res, events);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get container stdout/stderr ("application logs") from Fly's logs API.
|
||||
// Backfills from Fly's ~7-day retention; pass `next_token` (returned in the
|
||||
// response) to page forward for live tailing. Rate-limited because the
|
||||
// dashboard polls this every ~2s while live.
|
||||
router.get(
|
||||
'/:id/logs',
|
||||
verifyAdmin,
|
||||
computeLogsRateLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const svc = ComputeServicesService.getInstance();
|
||||
const existing = await svc.getService(req.params.id);
|
||||
|
||||
if (existing.projectId !== getProjectId(req)) {
|
||||
throw new AppError('Service not found', 404, ERROR_CODES.COMPUTE_SERVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const nextToken = typeof req.query.next_token === 'string' ? req.query.next_token : undefined;
|
||||
const logs = await svc.getServiceLogs(req.params.id, { limit, nextToken });
|
||||
|
||||
successResponse(res, logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as servicesRouter };
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
adminTableRecordUpdateRequestSchema,
|
||||
adminTableRecordLookupQuerySchema,
|
||||
adminTableRecordsCreateRequestSchema,
|
||||
adminTableRecordsDeleteRequestSchema,
|
||||
adminTableRecordsListQuerySchema,
|
||||
type AdminTableRecordsSortClause,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { AdminRecordService } from '@/services/database/admin-record.service.js';
|
||||
import {
|
||||
buildQualifiedTableKey,
|
||||
normalizeDatabaseSchemaName,
|
||||
} from '@/services/database/helpers.js';
|
||||
import { paginatedResponse, successResponse } from '@/utils/response.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import type { DatabaseResourceUpdate } from '@/utils/sql-parser.js';
|
||||
import type { DatabaseRecord } from '@/types/database.js';
|
||||
|
||||
function getValidationMessage(error: z.ZodError): string {
|
||||
return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', ');
|
||||
}
|
||||
|
||||
function parseSort(sort: string | undefined): AdminTableRecordsSortClause[] {
|
||||
if (!sort) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sort
|
||||
.split(',')
|
||||
.map((clause) => clause.trim())
|
||||
.filter(Boolean)
|
||||
.map((clause) => {
|
||||
const [columnName, direction = 'asc', ...rest] = clause.split(':');
|
||||
|
||||
if (!columnName || rest.length > 0) {
|
||||
throw new AppError(
|
||||
`Invalid sort clause "${clause}".`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Use sort values like "created_at:desc,name:asc".'
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedDirection = direction.toLowerCase();
|
||||
if (normalizedDirection !== 'asc' && normalizedDirection !== 'desc') {
|
||||
throw new AppError(
|
||||
`Invalid sort direction "${direction}".`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Use either "asc" or "desc" for sort direction.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
columnName,
|
||||
direction: normalizedDirection as AdminTableRecordsSortClause['direction'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastRecordChange(schemaName: string, tableName: string): void {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{
|
||||
resource: DataUpdateResourceType.DATABASE,
|
||||
data: {
|
||||
changes: [
|
||||
{ type: 'records', name: buildQualifiedTableKey(tableName, schemaName) },
|
||||
] as DatabaseResourceUpdate[],
|
||||
},
|
||||
},
|
||||
'system'
|
||||
);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
const recordsService = AdminRecordService.getInstance();
|
||||
|
||||
router.use(verifyAdmin);
|
||||
|
||||
router.get(
|
||||
'/tables/:tableName/records',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const validation = adminTableRecordsListQuerySchema.safeParse(req.query);
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const { limit, offset, search, sort, filterColumn, filterValue } = validation.data;
|
||||
const response = await recordsService.listRecords(schemaName, req.params.tableName, {
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
sort: parseSort(sort),
|
||||
filterColumn,
|
||||
filterValue,
|
||||
});
|
||||
|
||||
paginatedResponse(res, response.records, response.total, offset);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tables/:tableName/records/lookup',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const validation = adminTableRecordLookupQuerySchema.safeParse(req.query);
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const { column, value } = validation.data;
|
||||
const columns = Array.isArray(column) ? column : [column];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
const record = await recordsService.lookupRecord(
|
||||
schemaName,
|
||||
req.params.tableName,
|
||||
columns,
|
||||
values
|
||||
);
|
||||
|
||||
successResponse(res, record);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/tables/:tableName/records',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const validation = adminTableRecordsCreateRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const createdRecords = await recordsService.createRecords(
|
||||
schemaName,
|
||||
req.params.tableName,
|
||||
validation.data as DatabaseRecord[]
|
||||
);
|
||||
|
||||
broadcastRecordChange(schemaName, req.params.tableName);
|
||||
successResponse(res, createdRecords, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/tables/:tableName/records',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
|
||||
const bodyValidation = adminTableRecordUpdateRequestSchema.safeParse(req.body);
|
||||
if (!bodyValidation.success) {
|
||||
throw new AppError(
|
||||
getValidationMessage(bodyValidation.error),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const updatedRecord = await recordsService.updateRecord(
|
||||
schemaName,
|
||||
req.params.tableName,
|
||||
bodyValidation.data.pkKeys,
|
||||
bodyValidation.data.data as DatabaseRecord
|
||||
);
|
||||
|
||||
broadcastRecordChange(schemaName, req.params.tableName);
|
||||
successResponse(res, updatedRecord);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/tables/:tableName/records',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const validation = adminTableRecordsDeleteRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const deletedCount = await recordsService.deleteRecords(
|
||||
schemaName,
|
||||
req.params.tableName,
|
||||
validation.data.pkKeys
|
||||
);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
broadcastRecordChange(schemaName, req.params.tableName);
|
||||
}
|
||||
|
||||
successResponse(res, { deletedCount });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as databaseAdminRouter };
|
||||
@@ -0,0 +1,356 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { DatabaseAdvanceService } from '@/services/database/database-advance.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { DatabaseManager } from '@/infra/database/database.manager.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { upload, handleUploadError } from '@/api/middlewares/upload.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
rawSQLRequestSchema,
|
||||
exportRequestSchema,
|
||||
importRequestSchema,
|
||||
bulkUpsertRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { analyzeQuery, type DatabaseResourceUpdate } from '@/utils/sql-parser.js';
|
||||
import { buildQualifiedTableKey } from '@/services/database/helpers.js';
|
||||
|
||||
const router = Router();
|
||||
const dbAdvanceService = DatabaseAdvanceService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
/**
|
||||
* Invalidate column type cache for tables affected by schema-changing SQL
|
||||
*/
|
||||
function invalidateColumnTypeCacheFromChanges(changes: DatabaseResourceUpdate[]): void {
|
||||
for (const change of changes) {
|
||||
if (change.type === 'table' || change.type === 'tables') {
|
||||
if (change.name) {
|
||||
DatabaseManager.clearColumnTypeCache(change.name);
|
||||
} else {
|
||||
// DROP TABLE / CREATE TABLE don't preserve table name in the parser — clear all
|
||||
DatabaseManager.clearColumnTypeCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute raw SQL query with root privileges.
|
||||
* POST /api/database/advance/rawsql/unrestricted
|
||||
*
|
||||
* Root back door for project-admin-only operations that need full database owner privileges.
|
||||
*/
|
||||
router.post(
|
||||
'/rawsql/unrestricted',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = rawSQLRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { query, params = [] } = validation.data;
|
||||
|
||||
const response = await dbAdvanceService.executeRawSQL(query, params, true);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'EXECUTE_RAW_SQL_UNRESTRICTED',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
query: query.substring(0, 300), // Limit query length in audit log
|
||||
paramCount: params.length,
|
||||
rowsAffected: response.rowCount,
|
||||
executionRole: 'root',
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Broadcast changes if any modifying statements detected
|
||||
const changes = analyzeQuery(query);
|
||||
if (changes.length > 0) {
|
||||
invalidateColumnTypeCacheFromChanges(changes);
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.DATABASE, data: { changes } },
|
||||
'system'
|
||||
);
|
||||
}
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Unrestricted raw SQL execution error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Execute raw SQL query with project_admin privileges.
|
||||
* POST /api/database/advance/rawsql
|
||||
*/
|
||||
router.post('/rawsql', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = rawSQLRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { query, params = [] } = validation.data;
|
||||
|
||||
const response = await dbAdvanceService.executeRawSQL(query, params);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'EXECUTE_RAW_SQL',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
query: query.substring(0, 300), // Limit query length in audit log
|
||||
paramCount: params.length,
|
||||
rowsAffected: response.rowCount,
|
||||
executionRole: 'project_admin',
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Broadcast changes if any modifying statements detected
|
||||
const changes = analyzeQuery(query);
|
||||
if (changes.length > 0) {
|
||||
invalidateColumnTypeCacheFromChanges(changes);
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.DATABASE, data: { changes } },
|
||||
'system'
|
||||
);
|
||||
}
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Raw SQL execution error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Export database data
|
||||
* POST /api/database/advance/export
|
||||
*/
|
||||
router.post('/export', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = exportRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
tables,
|
||||
format,
|
||||
includeData,
|
||||
includeFunctions,
|
||||
includeSequences,
|
||||
includeViews,
|
||||
rowLimit,
|
||||
} = validation.data;
|
||||
const response = await dbAdvanceService.exportDatabase(
|
||||
tables,
|
||||
format,
|
||||
includeData,
|
||||
includeFunctions,
|
||||
includeSequences,
|
||||
includeViews,
|
||||
rowLimit
|
||||
);
|
||||
|
||||
// Log audit for database export
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'EXPORT_DATABASE',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
format: response.format,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Database export error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk upsert data from file upload (CSV/JSON)
|
||||
* POST /api/database/advance/bulk-upsert
|
||||
* Expects multipart/form-data with:
|
||||
* - file: CSV or JSON file
|
||||
* - table: Target table name
|
||||
* - upsertKey: Optional column for upsert operations
|
||||
*/
|
||||
router.post(
|
||||
'/bulk-upsert',
|
||||
verifyAdmin,
|
||||
upload.single('file'),
|
||||
handleUploadError,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
throw new AppError('File is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = bulkUpsertRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { schema, table, upsertKey } = validation.data;
|
||||
|
||||
const response = await dbAdvanceService.bulkUpsertFromFile(
|
||||
schema,
|
||||
table,
|
||||
req.file.buffer,
|
||||
req.file.originalname,
|
||||
upsertKey
|
||||
);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'BULK_UPSERT',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
schemaName: schema,
|
||||
table,
|
||||
filename: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
upsertKey: upsertKey || null,
|
||||
rowsAffected: response.rowsAffected,
|
||||
totalRecords: response.totalRecords,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{
|
||||
resource: DataUpdateResourceType.DATABASE,
|
||||
data: {
|
||||
changes: [
|
||||
{ type: 'records', name: buildQualifiedTableKey(table, schema) },
|
||||
] as DatabaseResourceUpdate[],
|
||||
},
|
||||
},
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Bulk upsert error:', error);
|
||||
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Import database data from SQL file
|
||||
* POST /api/database/advance/import
|
||||
* Expects a SQL file upload via multipart/form-data
|
||||
*/
|
||||
router.post(
|
||||
'/import',
|
||||
verifyAdmin,
|
||||
upload.single('file'),
|
||||
handleUploadError,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Validate request body
|
||||
const validation = importRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { truncate } = validation.data;
|
||||
|
||||
if (!req.file) {
|
||||
throw new AppError('SQL file is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const response = await dbAdvanceService.importDatabase(
|
||||
req.file.buffer,
|
||||
req.file.originalname,
|
||||
req.file.size,
|
||||
truncate
|
||||
);
|
||||
|
||||
// Log audit for database import
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'IMPORT_DATABASE',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
truncate,
|
||||
filename: response.filename,
|
||||
fileSize: response.fileSize,
|
||||
tablesAffected: response.tables.length,
|
||||
rowsImported: response.rowsImported,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Import may contain DDL — clear all column type caches
|
||||
DatabaseManager.clearColumnTypeCache();
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.DATABASE },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Database import error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createDatabaseBackupRequestSchema,
|
||||
renameDatabaseBackupRequestSchema,
|
||||
type CreateDatabaseBackupResponse,
|
||||
type DatabaseBackupsResponse,
|
||||
type DeleteDatabaseBackupResponse,
|
||||
type RestoreDatabaseBackupResponse,
|
||||
type UpdateDatabaseBackupResponse,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { DatabaseBackupService } from '@/services/database/database-backup.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { type DatabaseResourceUpdate } from '@/utils/sql-parser.js';
|
||||
|
||||
const router = Router();
|
||||
const backupService = DatabaseBackupService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
const uuidParamSchema = z.string().uuid();
|
||||
|
||||
// 22P02 (invalid uuid text) is not mapped by POSTGRES_ERROR_HANDLERS, so an
|
||||
// unvalidated :id would surface as a 500 instead of a 400.
|
||||
function parseBackupId(value: string): string {
|
||||
const validation = uuidParamSchema.safeParse(value);
|
||||
if (!validation.success) {
|
||||
throw new AppError('Invalid backup ID', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
return validation.data;
|
||||
}
|
||||
|
||||
function getValidationMessage(error: z.ZodError): string {
|
||||
return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', ');
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response<DatabaseBackupsResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const response = await backupService.listBackups();
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response<CreateDatabaseBackupResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createDatabaseBackupRequestSchema.safeParse(req.body ?? {});
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const actor = req.hasApiKey ? 'api-key' : (req.user?.id ?? null);
|
||||
const backup = await backupService.createBackup(validation.data, actor);
|
||||
|
||||
await auditService.log({
|
||||
actor: actor ?? undefined,
|
||||
action: 'CREATE_DATABASE_BACKUP',
|
||||
module: 'DATABASE',
|
||||
details: { id: backup.id, name: backup.name },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, backup, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response<UpdateDatabaseBackupResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const backupId = parseBackupId(req.params.id);
|
||||
const validation = renameDatabaseBackupRequestSchema.safeParse(req.body ?? {});
|
||||
if (!validation.success) {
|
||||
throw new AppError(getValidationMessage(validation.error), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const backup = await backupService.renameBackup(backupId, validation.data.name);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'RENAME_DATABASE_BACKUP',
|
||||
module: 'DATABASE',
|
||||
details: { id: backup.id, name: backup.name },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, backup);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response<DeleteDatabaseBackupResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const backupId = parseBackupId(req.params.id);
|
||||
await backupService.deleteBackup(backupId);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_DATABASE_BACKUP',
|
||||
module: 'DATABASE',
|
||||
details: { id: backupId },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, { message: 'Backup deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/restore',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response<RestoreDatabaseBackupResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const backupId = parseBackupId(req.params.id);
|
||||
await backupService.restoreBackup(backupId);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'RESTORE_DATABASE_BACKUP',
|
||||
module: 'DATABASE',
|
||||
details: { id: backupId },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{
|
||||
resource: DataUpdateResourceType.DATABASE,
|
||||
data: {
|
||||
changes: [{ type: 'tables' }, { type: 'records' }] as DatabaseResourceUpdate[],
|
||||
},
|
||||
},
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Database restored successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as databaseBackupsRouter };
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { databaseTablesRouter } from './tables.routes.js';
|
||||
import { databaseRecordsRouter } from './records.routes.js';
|
||||
import { databaseRpcRouter } from './rpc.routes.js';
|
||||
import databaseAdvanceRouter from './advance.routes.js';
|
||||
import { databaseMigrationsRouter } from './migrations.routes.js';
|
||||
import { databaseBackupsRouter } from './backups.routes.js';
|
||||
import { databaseAdminRouter } from './admin.routes.js';
|
||||
import { DatabaseService } from '@/services/database/database.service.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import { normalizeDatabaseSchemaName } from '@/services/database/helpers.js';
|
||||
import { isCloudEnvironment } from '@/utils/environment.js';
|
||||
|
||||
const router = Router();
|
||||
const databaseService = DatabaseService.getInstance();
|
||||
|
||||
// Mount database sub-routes
|
||||
router.use('/tables', databaseTablesRouter);
|
||||
router.use('/records', databaseRecordsRouter);
|
||||
router.use('/rpc', databaseRpcRouter);
|
||||
router.use('/advance', databaseAdvanceRouter);
|
||||
router.use('/migrations', databaseMigrationsRouter);
|
||||
if (!isCloudEnvironment()) {
|
||||
router.use('/backups', databaseBackupsRouter);
|
||||
}
|
||||
router.use('/admin', databaseAdminRouter);
|
||||
|
||||
router.get(
|
||||
'/schemas',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const response = await databaseService.getSchemas();
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get schemas error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all database functions
|
||||
* GET /api/database/functions
|
||||
*/
|
||||
router.get(
|
||||
'/functions',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const response = await databaseService.getFunctions(schemaName);
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get functions error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all database indexes
|
||||
* GET /api/database/indexes
|
||||
*/
|
||||
router.get('/indexes', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const response = await databaseService.getIndexes(schemaName);
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get indexes error:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all RLS policies
|
||||
* GET /api/database/policies
|
||||
*/
|
||||
router.get(
|
||||
'/policies',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const response = await databaseService.getPolicies(schemaName);
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get policies error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all database triggers
|
||||
* GET /api/database/triggers
|
||||
*/
|
||||
router.get(
|
||||
'/triggers',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const response = await databaseService.getTriggers(schemaName);
|
||||
successResponse(res, response);
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Get triggers error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createMigrationRequestSchema,
|
||||
type CreateMigrationResponse,
|
||||
type DatabaseMigrationsResponse,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { DatabaseMigrationService } from '@/services/database/database-migration.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { type DatabaseResourceUpdate } from '@/utils/sql-parser.js';
|
||||
|
||||
const router = Router();
|
||||
const migrationService = DatabaseMigrationService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response<DatabaseMigrationsResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const response = await migrationService.listMigrations();
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response<CreateMigrationResponse>, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createMigrationRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
const issues = validation.error.issues;
|
||||
throw new AppError(
|
||||
issues.length === 1
|
||||
? issues[0]?.message || 'Invalid migration request.'
|
||||
: issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await migrationService.createMigration(validation.data);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_CUSTOM_MIGRATION',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
name: result.migration.name,
|
||||
version: result.migration.version,
|
||||
statementCount: result.migration.statements.length,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{
|
||||
resource: DataUpdateResourceType.DATABASE,
|
||||
data: {
|
||||
changes: [{ type: 'migration' }, ...result.changes] as DatabaseResourceUpdate[],
|
||||
},
|
||||
},
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, result.migration, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as databaseMigrationsRouter };
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import axios from 'axios';
|
||||
import { AuthRequest, verifyUser } from '@/api/middlewares/auth.js';
|
||||
import { DatabaseManager } from '@/infra/database/database.manager.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import { validateTableName } from '@/utils/validations.js';
|
||||
import { DatabaseRecord } from '@/types/database.js';
|
||||
import { TEXT_LIKE_DATA_TYPES } from '@/utils/constants.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { DatabaseResourceUpdate } from '@/utils/sql-parser.js';
|
||||
import { PostgrestProxyService } from '@/services/database/postgrest-proxy.service.js';
|
||||
import { resolvePostgrestSchema } from '@/services/database/helpers.js';
|
||||
|
||||
const router = Router();
|
||||
const proxyService = PostgrestProxyService.getInstance();
|
||||
|
||||
/**
|
||||
* Helper to handle PostgREST proxy errors
|
||||
*/
|
||||
function handleProxyError(error: unknown, res: Response, next: NextFunction) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
res.status(error.response.status).json(error.response.data);
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward database table requests to PostgREST
|
||||
*/
|
||||
const forwardToPostgrest = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const { tableName, path: wildcardPath } = req.params;
|
||||
const path = wildcardPath ? `/${tableName}/${wildcardPath}` : `/${tableName}`;
|
||||
|
||||
try {
|
||||
// Validate table name
|
||||
try {
|
||||
validateTableName(tableName);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError('Invalid table name', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Resolve the target schema the native PostgREST way: an explicit ?schema=
|
||||
// is desugared into the Accept-Profile/Content-Profile header (and stripped
|
||||
// from the forwarded query); a client-sent profile header is honored as-is.
|
||||
const {
|
||||
schemaName,
|
||||
query: forwardedQuery,
|
||||
headers: forwardedHeaders,
|
||||
} = resolvePostgrestSchema(
|
||||
req.method,
|
||||
req.query as Record<string, unknown>,
|
||||
req.headers as Record<string, string | string[] | undefined>
|
||||
);
|
||||
|
||||
// Process request body for POST/PATCH/PUT (filter empty values based on column types)
|
||||
const method = req.method.toUpperCase();
|
||||
let body = req.body;
|
||||
|
||||
if (['POST', 'PATCH', 'PUT'].includes(method) && body && typeof body === 'object') {
|
||||
const columnTypeMap = await DatabaseManager.getColumnTypeMap(tableName, schemaName);
|
||||
if (Array.isArray(body)) {
|
||||
body = body.map((item) => {
|
||||
if (item && typeof item === 'object') {
|
||||
const filtered: DatabaseRecord = {};
|
||||
for (const key in item) {
|
||||
if (!TEXT_LIKE_DATA_TYPES.has(columnTypeMap[key] ?? '') && item[key] === '') {
|
||||
continue;
|
||||
}
|
||||
filtered[key] = item[key];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
for (const key in body) {
|
||||
if (!TEXT_LIKE_DATA_TYPES.has(columnTypeMap[key] ?? '') && body[key] === '') {
|
||||
delete body[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to PostgREST via service
|
||||
const proxyRequest = {
|
||||
method: req.method,
|
||||
path,
|
||||
query: forwardedQuery,
|
||||
headers: forwardedHeaders,
|
||||
body: ['POST', 'PUT', 'PATCH'].includes(req.method) ? body : undefined,
|
||||
};
|
||||
|
||||
const result =
|
||||
req.user?.role === 'project_admin' || req.hasApiKey === true
|
||||
? await proxyService.forwardAsAdmin(proxyRequest)
|
||||
: req.user && req.user.role !== 'anon'
|
||||
? await proxyService.forwardAsUser(proxyRequest, req.user)
|
||||
: await proxyService.forwardAsAnon(proxyRequest);
|
||||
|
||||
// Forward response headers
|
||||
const headers = PostgrestProxyService.filterHeaders(result.headers);
|
||||
Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value));
|
||||
|
||||
// Handle empty responses
|
||||
let responseData = result.data;
|
||||
if (
|
||||
result.data === undefined ||
|
||||
(typeof result.data === 'string' && result.data.trim() === '')
|
||||
) {
|
||||
responseData = [];
|
||||
}
|
||||
|
||||
// Broadcast socket events for mutations
|
||||
if (['POST', 'DELETE'].includes(method)) {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{
|
||||
resource: DataUpdateResourceType.DATABASE,
|
||||
data: { changes: [{ type: 'records', name: tableName }] as DatabaseResourceUpdate[] },
|
||||
},
|
||||
'system'
|
||||
);
|
||||
}
|
||||
|
||||
successResponse(res, responseData, result.status);
|
||||
} catch (error) {
|
||||
handleProxyError(error, res, next);
|
||||
}
|
||||
};
|
||||
|
||||
// Forward all database operations to PostgREST (requires authentication)
|
||||
router.all('/:tableName', verifyUser, forwardToPostgrest);
|
||||
router.all('/:tableName/*path', verifyUser, forwardToPostgrest);
|
||||
|
||||
export { router as databaseRecordsRouter };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import axios from 'axios';
|
||||
import { AuthRequest, verifyUser } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import { validateFunctionName } from '@/utils/validations.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { PostgrestProxyService } from '@/services/database/postgrest-proxy.service.js';
|
||||
import { resolvePostgrestSchema } from '@/services/database/helpers.js';
|
||||
|
||||
const router = Router();
|
||||
const proxyService = PostgrestProxyService.getInstance();
|
||||
|
||||
/**
|
||||
* Helper to handle PostgREST proxy errors
|
||||
*/
|
||||
function handleProxyError(error: unknown, res: Response, next: NextFunction) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
res.status(error.response.status).json(error.response.data);
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward RPC calls to PostgREST
|
||||
*/
|
||||
const forwardRpcToPostgrest = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const { functionName } = req.params;
|
||||
|
||||
try {
|
||||
// Validate function name
|
||||
try {
|
||||
validateFunctionName(functionName);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(`Invalid function name: ${functionName}`, 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Resolve the target schema the native PostgREST way: ?schema= is desugared
|
||||
// into the profile header (RPC uses Content-Profile for POST, Accept-Profile
|
||||
// for GET) and stripped from the forwarded query; a client-sent profile
|
||||
// header is honored as-is.
|
||||
const { query: forwardedQuery, headers: forwardedHeaders } = resolvePostgrestSchema(
|
||||
req.method,
|
||||
req.query as Record<string, unknown>,
|
||||
req.headers as Record<string, string | string[] | undefined>
|
||||
);
|
||||
|
||||
const proxyRequest = {
|
||||
method: req.method,
|
||||
path: `/rpc/${functionName}`,
|
||||
query: forwardedQuery,
|
||||
headers: forwardedHeaders,
|
||||
body: req.body,
|
||||
};
|
||||
|
||||
const result =
|
||||
req.user?.role === 'project_admin' || req.hasApiKey === true
|
||||
? await proxyService.forwardAsAdmin(proxyRequest)
|
||||
: req.user && req.user.role !== 'anon'
|
||||
? await proxyService.forwardAsUser(proxyRequest, req.user)
|
||||
: await proxyService.forwardAsAnon(proxyRequest);
|
||||
|
||||
const headers = PostgrestProxyService.filterHeaders(result.headers);
|
||||
Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value));
|
||||
|
||||
let responseData = result.data;
|
||||
if (
|
||||
result.data === undefined ||
|
||||
(typeof result.data === 'string' && result.data.trim() === '')
|
||||
) {
|
||||
responseData = null;
|
||||
}
|
||||
|
||||
successResponse(res, responseData, result.status);
|
||||
} catch (error) {
|
||||
handleProxyError(error, res, next);
|
||||
}
|
||||
};
|
||||
|
||||
router.all('/:functionName', verifyUser, forwardRpcToPostgrest);
|
||||
|
||||
export { router as databaseRpcRouter };
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { DatabaseTableService } from '@/services/database/database-table.service.js';
|
||||
import { DatabaseManager } from '@/infra/database/database.manager.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createTableRequestSchema,
|
||||
updateTableSchemaRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { normalizeDatabaseSchemaName } from '@/services/database/helpers.js';
|
||||
|
||||
const router = Router();
|
||||
const tableService = DatabaseTableService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
// All table routes accept either JWT token or API key authentication
|
||||
// router.use(verifyAdmin);
|
||||
|
||||
// List all tables
|
||||
router.get('/', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schemaName = normalizeDatabaseSchemaName(_req.query.schema);
|
||||
const tables = await tableService.listTables(schemaName);
|
||||
successResponse(res, tables);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new table
|
||||
router.post('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createTableRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please check the request body, it must conform with the CreateTableRequest schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const { tableName, columns, foreignKeys, rlsEnabled } = validation.data;
|
||||
const result = await tableService.createTable(
|
||||
schemaName,
|
||||
tableName,
|
||||
columns,
|
||||
foreignKeys,
|
||||
rlsEnabled
|
||||
);
|
||||
|
||||
DatabaseManager.clearColumnTypeCache(tableName, schemaName);
|
||||
|
||||
// Log audit for table creation
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_TABLE',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
schemaName,
|
||||
tableName,
|
||||
columns,
|
||||
rlsEnabled,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, result, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get table schema
|
||||
router.get(
|
||||
'/:tableName/schema',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const schema = await tableService.getTableSchema(schemaName, tableName);
|
||||
successResponse(res, schema);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update table schema
|
||||
router.patch(
|
||||
'/:tableName/schema',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
|
||||
const validation = updateTableSchemaRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT,
|
||||
'Please check the request body, it must conform with the UpdateTableRequest schema.'
|
||||
);
|
||||
}
|
||||
|
||||
const operations = validation.data;
|
||||
const result = await tableService.updateTableSchema(schemaName, tableName, operations);
|
||||
|
||||
DatabaseManager.clearColumnTypeCache(tableName, schemaName);
|
||||
|
||||
// Log audit for table schema update
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_TABLE',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
schemaName,
|
||||
tableName,
|
||||
operations,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a table
|
||||
router.delete(
|
||||
'/:tableName',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const schemaName = normalizeDatabaseSchemaName(req.query.schema);
|
||||
const result = await tableService.deleteTable(schemaName, tableName);
|
||||
|
||||
DatabaseManager.clearColumnTypeCache(tableName, schemaName);
|
||||
|
||||
// Log audit for table deletion
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_TABLE',
|
||||
module: 'DATABASE',
|
||||
details: {
|
||||
schemaName,
|
||||
tableName,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as databaseTablesRouter };
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { DeploymentService } from '@/services/deployments/deployment.service.js';
|
||||
import { VercelProvider } from '@/providers/deployments/vercel.provider.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { deploymentsWriteLimiter } from '@/api/middlewares/rate-limiters.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { ERROR_CODES, upsertEnvVarsRequestSchema } from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const deploymentService = DeploymentService.getInstance();
|
||||
const vercelProvider = VercelProvider.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
/**
|
||||
* List all environment variables
|
||||
* GET /api/deployments/env-vars
|
||||
*/
|
||||
router.get('/', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!deploymentService.isConfigured()) {
|
||||
throw new AppError(
|
||||
'Deployment service is not configured. Please set VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID environment variables.',
|
||||
503,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const envVars = await vercelProvider.listEnvironmentVariables();
|
||||
successResponse(res, { envVars });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create or update environment variables
|
||||
* POST /api/deployments/env-vars
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!deploymentService.isConfigured()) {
|
||||
throw new AppError(
|
||||
'Deployment service is not configured. Please set VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID environment variables.',
|
||||
503,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const validationResult = upsertEnvVarsRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { envVars } = validationResult.data;
|
||||
|
||||
await vercelProvider.upsertEnvironmentVariables(envVars);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPSERT_ENV_VARS',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { count: envVars.length, keys: envVars.map((envVar) => envVar.key) },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
success: true,
|
||||
message: `${envVars.length} environment variables have been saved successfully`,
|
||||
count: envVars.length,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get a single environment variable with decrypted value
|
||||
* GET /api/deployments/env-vars/:id
|
||||
*/
|
||||
router.get('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!deploymentService.isConfigured()) {
|
||||
throw new AppError(
|
||||
'Deployment service is not configured. Please set VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID environment variables.',
|
||||
503,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
const envVar = await vercelProvider.getEnvironmentVariable(id);
|
||||
|
||||
successResponse(res, { envVar });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete an environment variable
|
||||
* DELETE /api/deployments/env-vars/:id
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!deploymentService.isConfigured()) {
|
||||
throw new AppError(
|
||||
'Deployment service is not configured. Please set VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID environment variables.',
|
||||
503,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
await vercelProvider.deleteEnvironmentVariable(id);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_ENV_VAR',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { envId: id },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: 'Environment variable has been deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as envVarsRouter };
|
||||
@@ -0,0 +1,469 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { DeploymentService } from '@/services/deployments/deployment.service.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { deploymentsWriteLimiter } from '@/api/middlewares/rate-limiters.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse, paginatedResponse } from '@/utils/response.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createDirectDeploymentRequestSchema,
|
||||
startDeploymentRequestSchema,
|
||||
updateSlugRequestSchema,
|
||||
addCustomDomainRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { envVarsRouter } from './env-vars.routes.js';
|
||||
|
||||
const router = Router();
|
||||
const deploymentService = DeploymentService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
const domainParamSchema = addCustomDomainRequestSchema.shape.domain;
|
||||
const uuidParamSchema = z.string().uuid();
|
||||
|
||||
// Mount sub-routers first to avoid conflicts with parameterized routes
|
||||
router.use('/env-vars', envVarsRouter);
|
||||
|
||||
/**
|
||||
* Create a new deployment record with WAITING status
|
||||
* Returns presigned upload info for the legacy source zip flow
|
||||
* POST /api/deployments
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const response = await deploymentService.createDeployment();
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_DEPLOYMENT',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { id: response.id },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, response, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a new direct-upload deployment record with WAITING status
|
||||
* POST /api/deployments/direct
|
||||
*/
|
||||
router.post(
|
||||
'/direct',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = createDirectDeploymentRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const response = await deploymentService.createDirectDeployment(validationResult.data);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_DIRECT_DEPLOYMENT',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { id: response.id, fileCount: response.files.length },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, response, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Stream one direct deployment file through the backend to Vercel
|
||||
* PUT /api/deployments/:id/files/:fileId/content
|
||||
*/
|
||||
// Intentionally NOT rate-limited: this is the per-file content sub-step of a
|
||||
// direct deploy. The parent POST /direct already consumes a deploymentsWriteLimiter
|
||||
// token; capping each chunk separately would break legit deploys with >3 files.
|
||||
router.put(
|
||||
'/:id/files/:fileId/content',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const idValidation = uuidParamSchema.safeParse(req.params.id);
|
||||
if (!idValidation.success) {
|
||||
throw new AppError('Invalid deployment ID', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const fileIdValidation = uuidParamSchema.safeParse(req.params.fileId);
|
||||
if (!fileIdValidation.success) {
|
||||
throw new AppError('Invalid deployment file ID', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const contentTypeHeader = req.headers['content-type'];
|
||||
const contentType = Array.isArray(contentTypeHeader)
|
||||
? contentTypeHeader[0]
|
||||
: (contentTypeHeader ?? '');
|
||||
const normalizedContentType = contentType.split(';')[0].trim().toLowerCase();
|
||||
|
||||
if (normalizedContentType !== 'application/octet-stream') {
|
||||
throw new AppError(
|
||||
'Deployment file content must be uploaded as application/octet-stream.',
|
||||
415,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
req.on('aborted', () => abortController.abort());
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const response = await deploymentService.uploadDeploymentFileContent(
|
||||
idValidation.data,
|
||||
fileIdValidation.data,
|
||||
req,
|
||||
{
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Start a deployment after source files are available
|
||||
* POST /api/deployments/:id/start
|
||||
*/
|
||||
router.post(
|
||||
'/:id/start',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const validationResult = startDeploymentRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const deployment = await deploymentService.startDeployment(id, validationResult.data);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'START_DEPLOYMENT',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: {
|
||||
id: deployment.id,
|
||||
providerDeploymentId: deployment.providerDeploymentId,
|
||||
provider: deployment.provider,
|
||||
status: deployment.status,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, deployment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* List all deployments
|
||||
* GET /api/deployments
|
||||
*/
|
||||
router.get('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const limit = Math.max(1, parseInt(req.query.limit as string) || 50);
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const { deployments, total } = await deploymentService.listDeployments(limit, offset);
|
||||
|
||||
paginatedResponse(res, deployments, total, offset);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get deployment metadata (current deployment, domain URLs)
|
||||
* GET /api/deployments/metadata
|
||||
*/
|
||||
router.get(
|
||||
'/metadata',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const metadata = await deploymentService.getMetadata();
|
||||
successResponse(res, metadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update custom slug for the project
|
||||
* PUT /api/deployments/slug
|
||||
*/
|
||||
router.put(
|
||||
'/slug',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = updateSlugRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deploymentService.updateSlug(validationResult.data.slug);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_DEPLOYMENT_SLUG',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { slug: result.slug, domain: result.domain },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Custom Domain Routes (user-owned domains)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* List all custom domains
|
||||
* GET /api/deployments/domains
|
||||
*/
|
||||
router.get(
|
||||
'/domains',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await deploymentService.listCustomDomains();
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Add a custom domain
|
||||
* POST /api/deployments/domains
|
||||
*/
|
||||
router.post(
|
||||
'/domains',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = addCustomDomainRequestSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const domain = await deploymentService.addCustomDomain(validationResult.data.domain);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'ADD_CUSTOM_DOMAIN',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { domain: domain.domain },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, domain, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Verify a custom domain's DNS configuration
|
||||
* POST /api/deployments/domains/:domain/verify
|
||||
*/
|
||||
router.post(
|
||||
'/domains/:domain/verify',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = domainParamSchema.safeParse(req.params.domain);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((issue) => issue.message).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deploymentService.verifyCustomDomain(validationResult.data);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove a custom domain
|
||||
* DELETE /api/deployments/domains/:domain
|
||||
*/
|
||||
router.delete(
|
||||
'/domains/:domain',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validationResult = domainParamSchema.safeParse(req.params.domain);
|
||||
if (!validationResult.success) {
|
||||
throw new AppError(
|
||||
validationResult.error.issues.map((issue) => issue.message).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const domain = validationResult.data;
|
||||
await deploymentService.removeCustomDomain(domain);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'REMOVE_CUSTOM_DOMAIN',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { domain },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, { success: true, message: `Domain ${domain} removed` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get deployment by database ID
|
||||
* GET /api/deployments/:id
|
||||
*/
|
||||
router.get('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deployment = await deploymentService.getDeploymentById(id);
|
||||
|
||||
if (!deployment) {
|
||||
throw new AppError(`Deployment not found: ${id}`, 404, ERROR_CODES.DEPLOYMENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
successResponse(res, deployment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync deployment status from Vercel and update database
|
||||
* POST /api/deployments/:id/sync
|
||||
*/
|
||||
// Intentionally NOT rate-limited: this route triggers a Vercel GET
|
||||
// (vercelProvider.getDeployment) — a read, not a write. The
|
||||
// deploymentsWriteLimiter is reserved for endpoints that consume Vercel's
|
||||
// write quotas (deployment creation, env-var writes, domain CRUD).
|
||||
router.post(
|
||||
'/:id/sync',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const deployment = await deploymentService.syncDeploymentById(id);
|
||||
|
||||
if (!deployment) {
|
||||
throw new AppError(`Deployment not found: ${id}`, 404, ERROR_CODES.DEPLOYMENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
successResponse(res, deployment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Cancel a deployment
|
||||
* POST /api/deployments/:id/cancel
|
||||
*/
|
||||
router.post(
|
||||
'/:id/cancel',
|
||||
verifyAdmin,
|
||||
deploymentsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await deploymentService.cancelDeploymentById(id);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CANCEL_DEPLOYMENT',
|
||||
module: 'DEPLOYMENTS',
|
||||
details: { id },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `Deployment ${id} has been cancelled`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as deploymentsRouter };
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
DocTypeSchema,
|
||||
SdkFeatureSchema,
|
||||
SdkLanguageSchema,
|
||||
docTypeSchema,
|
||||
sdkFeatureSchema,
|
||||
sdkLanguageSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Resolve the documentation root once for the module.
|
||||
// PROJECT_ROOT is set in docker-compose.yml. Otherwise, fallback to the monorepo root.
|
||||
const PROJECT_ROOT = process.env.PROJECT_ROOT || path.resolve(__dirname, '../../../../..');
|
||||
const DOCS_ROOT = path.join(PROJECT_ROOT, 'docs');
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Process MDX content to resolve snippet imports and component usage.
|
||||
* Only handles imports from the /snippets/ directory for security.
|
||||
* Handles patterns like:
|
||||
* import SwiftSdkInstallation from '/snippets/swift-sdk-installation.mdx';
|
||||
* <SwiftSdkInstallation />
|
||||
* Non-snippet imports are preserved in the output.
|
||||
*/
|
||||
async function processSnippets(content: string, docsRoot: string): Promise<string> {
|
||||
// Extract all import statements
|
||||
const importRegex = /^import\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/gm;
|
||||
const snippetImports: Map<string, string> = new Map();
|
||||
const snippetImportLines: Set<string> = new Set();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const [fullMatch, componentName, importPath] = match;
|
||||
// Only process imports from /snippets/ directory
|
||||
if (importPath.startsWith('/snippets/')) {
|
||||
snippetImports.set(componentName, importPath);
|
||||
snippetImportLines.add(fullMatch);
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove snippet import lines, preserve other imports
|
||||
let processedContent = content;
|
||||
for (const importLine of snippetImportLines) {
|
||||
processedContent = processedContent.replace(importLine, '');
|
||||
}
|
||||
|
||||
// Resolve the allowed snippets directory to an absolute path for security check
|
||||
const allowedDir = path.resolve(docsRoot, 'snippets');
|
||||
|
||||
// Replace component usages with actual snippet content
|
||||
for (const [componentName, importPath] of snippetImports) {
|
||||
// Resolve snippet path
|
||||
const snippetPath = path.resolve(docsRoot, importPath.replace(/^\//, ''));
|
||||
|
||||
// Security check: ensure resolved path is strictly inside docsRoot/snippets
|
||||
const relativePath = path.relative(allowedDir, snippetPath);
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
console.warn(`Snippet path traversal blocked: ${importPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
let snippetContent = await readFile(snippetPath, 'utf-8');
|
||||
|
||||
// Remove frontmatter from snippet if present
|
||||
snippetContent = snippetContent.replace(/^---[\s\S]*?---\s*/, '');
|
||||
|
||||
// Replace self-closing component tag: <ComponentName />
|
||||
const selfClosingRegex = new RegExp(`<${componentName}\\s*/>`, 'g');
|
||||
processedContent = processedContent.replace(selfClosingRegex, snippetContent.trim());
|
||||
|
||||
// Replace component with children (if any): <ComponentName>...</ComponentName>
|
||||
const withChildrenRegex = new RegExp(
|
||||
`<${componentName}\\s*>[\\s\\S]*?</${componentName}>`,
|
||||
'g'
|
||||
);
|
||||
processedContent = processedContent.replace(withChildrenRegex, snippetContent.trim());
|
||||
} catch {
|
||||
// If snippet file not found, log the import path (not absolute path) and leave component tag as-is
|
||||
console.warn(`Snippet not found: ${importPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up extra blank lines
|
||||
processedContent = processedContent.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return processedContent.trim();
|
||||
}
|
||||
|
||||
// Legacy documentation map for GET /api/docs/:docType endpoint
|
||||
// Only contains keys defined in DocTypeSchema for type safety
|
||||
export const LEGACY_DOCS_MAP: Record<DocTypeSchema, string> = {
|
||||
instructions: '../.agents/docs/insforge-instructions-sdk.md',
|
||||
'db-sdk': 'sdks/typescript/database.mdx',
|
||||
'auth-sdk': 'sdks/typescript/auth.mdx',
|
||||
'storage-sdk': 'sdks/typescript/storage.mdx',
|
||||
'functions-sdk': 'sdks/typescript/functions.mdx',
|
||||
'ai-integration-sdk': 'sdks/typescript/ai.mdx',
|
||||
'real-time': '../.agents/docs/real-time.md',
|
||||
deployment: '../.agents/docs/deployment.md',
|
||||
payments: '../.agents/docs/payments.md',
|
||||
};
|
||||
|
||||
// SDK documentation map for GET /api/docs/:docFeature/:docLanguage endpoint
|
||||
// Supports feature × language combinations with type safety
|
||||
export const SDK_DOCS_MAP: Record<SdkFeatureSchema, Partial<Record<SdkLanguageSchema, string>>> = {
|
||||
db: {
|
||||
typescript: 'sdks/typescript/database.mdx',
|
||||
swift: 'sdks/swift/database.mdx',
|
||||
kotlin: 'sdks/kotlin/database.mdx',
|
||||
'rest-api': 'sdks/rest/database.mdx',
|
||||
},
|
||||
storage: {
|
||||
typescript: 'sdks/typescript/storage.mdx',
|
||||
swift: 'sdks/swift/storage.mdx',
|
||||
kotlin: 'sdks/kotlin/storage.mdx',
|
||||
'rest-api': 'sdks/rest/storage.mdx',
|
||||
},
|
||||
functions: {
|
||||
typescript: 'sdks/typescript/functions.mdx',
|
||||
swift: 'sdks/swift/functions.mdx',
|
||||
kotlin: 'sdks/kotlin/functions.mdx',
|
||||
'rest-api': 'sdks/rest/functions.mdx',
|
||||
},
|
||||
auth: {
|
||||
typescript: 'sdks/typescript/auth.mdx',
|
||||
swift: 'sdks/swift/auth.mdx',
|
||||
kotlin: 'sdks/kotlin/auth.mdx',
|
||||
'rest-api': 'sdks/rest/auth.mdx',
|
||||
},
|
||||
ai: {
|
||||
typescript: 'sdks/typescript/ai.mdx',
|
||||
swift: 'sdks/swift/ai.mdx',
|
||||
kotlin: 'sdks/kotlin/ai.mdx',
|
||||
'rest-api': 'sdks/rest/ai.mdx',
|
||||
},
|
||||
realtime: {
|
||||
typescript: 'sdks/typescript/realtime.mdx',
|
||||
swift: 'sdks/swift/realtime.mdx',
|
||||
kotlin: 'sdks/kotlin/realtime.mdx',
|
||||
'rest-api': 'sdks/rest/realtime.mdx',
|
||||
},
|
||||
payments: {
|
||||
typescript: 'sdks/typescript/payments.mdx',
|
||||
},
|
||||
};
|
||||
|
||||
// GET /api/docs/:docType - Get specific documentation (legacy endpoint)
|
||||
router.get('/:docType', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { docType } = req.params;
|
||||
|
||||
// Validate doc type using Zod enum
|
||||
const parsed = docTypeSchema.safeParse(docType);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('Documentation not found', 404, ERROR_CODES.DOCS_NOT_FOUND);
|
||||
}
|
||||
|
||||
const docFileName = LEGACY_DOCS_MAP[parsed.data];
|
||||
|
||||
// Read the documentation file
|
||||
const filePath = path.join(DOCS_ROOT, docFileName);
|
||||
|
||||
// Security check: ensure path is within either docs/ or .agents/docs/
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedDocsRoot = path.resolve(DOCS_ROOT);
|
||||
const resolvedAgentsRoot = path.resolve(PROJECT_ROOT, '.agents/docs');
|
||||
|
||||
if (
|
||||
!resolvedPath.startsWith(resolvedDocsRoot) &&
|
||||
!resolvedPath.startsWith(resolvedAgentsRoot)
|
||||
) {
|
||||
throw new AppError('Invalid documentation path', 403, ERROR_CODES.FORBIDDEN);
|
||||
}
|
||||
|
||||
const rawContent = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Process snippet imports and replace component tags with actual content
|
||||
const content = await processSnippets(rawContent, DOCS_ROOT);
|
||||
|
||||
// Traditional REST: return documentation directly
|
||||
return successResponse(res, {
|
||||
type: docType,
|
||||
content,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/docs/:docFeature/:docLanguage - Get specific SDK documentation
|
||||
router.get('/:docFeature/:docLanguage', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { docFeature, docLanguage } = req.params;
|
||||
|
||||
// Validate doc feature and language using Zod enums
|
||||
const parsedFeature = sdkFeatureSchema.safeParse(docFeature);
|
||||
const parsedLanguage = sdkLanguageSchema.safeParse(docLanguage);
|
||||
|
||||
if (!parsedFeature.success || !parsedLanguage.success) {
|
||||
throw new AppError('Documentation not found', 404, ERROR_CODES.DOCS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Look up the documentation file from SDK_DOCS_MAP
|
||||
const featureDocs = SDK_DOCS_MAP[parsedFeature.data];
|
||||
const docFileName = featureDocs[parsedLanguage.data];
|
||||
|
||||
if (!docFileName) {
|
||||
throw new AppError('Documentation not found', 404, ERROR_CODES.DOCS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Construct docType for response
|
||||
const docType =
|
||||
parsedLanguage.data === 'rest-api'
|
||||
? `${parsedFeature.data}-${parsedLanguage.data}`
|
||||
: `${parsedFeature.data}-sdk-${parsedLanguage.data}`;
|
||||
|
||||
// Read the documentation file
|
||||
const filePath = path.join(DOCS_ROOT, docFileName);
|
||||
|
||||
// Security check: ensure path is within either docs/ or .agents/docs/
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedDocsRoot = path.resolve(DOCS_ROOT);
|
||||
const resolvedAgentsRoot = path.resolve(PROJECT_ROOT, '.agents/docs');
|
||||
|
||||
if (
|
||||
!resolvedPath.startsWith(resolvedDocsRoot) &&
|
||||
!resolvedPath.startsWith(resolvedAgentsRoot)
|
||||
) {
|
||||
throw new AppError('Invalid documentation path', 403, ERROR_CODES.FORBIDDEN);
|
||||
}
|
||||
|
||||
const rawContent = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Process snippet imports and replace component tags with actual content
|
||||
const content = await processSnippets(rawContent, DOCS_ROOT);
|
||||
|
||||
// Traditional REST: return documentation directly
|
||||
return successResponse(res, {
|
||||
type: docType,
|
||||
content,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/docs - List available documentation
|
||||
router.get('/', (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// List legacy documentation
|
||||
const legacyDocs = (Object.keys(LEGACY_DOCS_MAP) as DocTypeSchema[]).map((key) => ({
|
||||
type: key,
|
||||
filename: LEGACY_DOCS_MAP[key],
|
||||
endpoint: `/api/docs/${key}`,
|
||||
}));
|
||||
|
||||
// List SDK documentation (feature × language combinations)
|
||||
const sdkDocs: { type: string; filename: string; endpoint: string }[] = [];
|
||||
for (const [feature, languages] of Object.entries(SDK_DOCS_MAP)) {
|
||||
for (const [language, filename] of Object.entries(languages)) {
|
||||
if (filename) {
|
||||
const type =
|
||||
language === 'rest-api' ? `${feature}-${language}` : `${feature}-sdk-${language}`;
|
||||
sdkDocs.push({
|
||||
type,
|
||||
filename,
|
||||
endpoint: `/api/docs/${feature}/${language}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traditional REST: return list directly
|
||||
return successResponse(res, [...legacyDocs, ...sdkDocs]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as docsRouter };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest, verifyUser } from '@/api/middlewares/auth.js';
|
||||
import { EmailService } from '@/services/email/email.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES, sendRawEmailRequestSchema } from '@insforge/shared-schemas';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
|
||||
const router = Router();
|
||||
const emailService = EmailService.getInstance();
|
||||
|
||||
/**
|
||||
* POST /api/email/send-raw
|
||||
* Send a raw/custom email with explicit to, subject, and body
|
||||
*/
|
||||
router.post(
|
||||
'/send-raw',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Anonymous clients (anon key, role 'anon') must not be able to send
|
||||
// arbitrary emails. Authenticated users and admin API keys are allowed;
|
||||
// an admin API key leaves req.user undefined, so only 'anon' is blocked.
|
||||
if (req.user?.role === 'anon') {
|
||||
throw new AppError(
|
||||
'Sending emails requires an authenticated user',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const validation = sendRawEmailRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(JSON.stringify(validation.error.issues), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
await emailService.sendRaw(validation.data);
|
||||
|
||||
successResponse(res, {});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const emailRouter = router;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest, verifyAdmin } from '@/api/middlewares/auth.js';
|
||||
import { functionsWriteLimiter } from '@/api/middlewares/rate-limiters.js';
|
||||
import { FunctionService } from '@/services/functions/function.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
uploadFunctionRequestSchema,
|
||||
updateFunctionRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
|
||||
const router = Router();
|
||||
const functionService = FunctionService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
/**
|
||||
* GET /api/functions
|
||||
* List all edge functions
|
||||
*/
|
||||
router.get('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await functionService.listFunctions();
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
logger.error('Failed to list functions', { error });
|
||||
next(new AppError('Failed to list functions', 500, ERROR_CODES.INTERNAL_ERROR));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/functions/:slug
|
||||
* Get specific function details including code
|
||||
*/
|
||||
router.get('/:slug', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const func = await functionService.getFunction(slug);
|
||||
|
||||
if (!func) {
|
||||
throw new AppError('Function not found', 404, ERROR_CODES.FUNCTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
successResponse(res, func);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/functions
|
||||
* Create a new function
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
verifyAdmin,
|
||||
functionsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = uploadFunctionRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(JSON.stringify(validation.error.issues), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const result = await functionService.createFunction(validation.data);
|
||||
|
||||
// Log audit event
|
||||
logger.info(
|
||||
`Function ${result.function.name} (${result.function.slug}) created by ${req.hasApiKey ? 'api-key' : req.user?.id}`
|
||||
);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_FUNCTION',
|
||||
module: 'FUNCTIONS',
|
||||
details: {
|
||||
functionId: result.function.id,
|
||||
slug: result.function.slug,
|
||||
name: result.function.name,
|
||||
status: result.function.status,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.FUNCTIONS },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
success: !result.deployment || result.deployment.status === 'success',
|
||||
function: result.function,
|
||||
deployment: result.deployment,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/functions/:slug
|
||||
* Update an existing function
|
||||
*/
|
||||
router.put(
|
||||
'/:slug',
|
||||
verifyAdmin,
|
||||
functionsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const validation = updateFunctionRequestSchema.safeParse(req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
throw new AppError(JSON.stringify(validation.error.issues), 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const result = await functionService.updateFunction(slug, validation.data);
|
||||
|
||||
if (!result) {
|
||||
throw new AppError('Function not found', 404, ERROR_CODES.FUNCTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
logger.info(`Function ${slug} updated by ${req.hasApiKey ? 'api-key' : req.user?.id}`);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_FUNCTION',
|
||||
module: 'FUNCTIONS',
|
||||
details: {
|
||||
slug,
|
||||
changes: validation.data,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.FUNCTIONS, data: { slug } },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
success: !result.deployment || result.deployment.status === 'success',
|
||||
function: result.function,
|
||||
deployment: result.deployment,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/functions/:slug
|
||||
* Delete a function
|
||||
*/
|
||||
router.delete(
|
||||
'/:slug',
|
||||
verifyAdmin,
|
||||
functionsWriteLimiter,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const deleted = await functionService.deleteFunction(slug);
|
||||
|
||||
if (!deleted) {
|
||||
throw new AppError('Function not found', 404, ERROR_CODES.FUNCTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
logger.info(`Function ${slug} deleted by ${req.hasApiKey ? 'api-key' : req.user?.id}`);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_FUNCTION',
|
||||
module: 'FUNCTIONS',
|
||||
details: {
|
||||
slug,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.FUNCTIONS },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `Function ${slug} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { LogService } from '@/services/logs/log.service.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { AuthRequest, verifyAdmin } from '@/api/middlewares/auth.js';
|
||||
import { successResponse, paginatedResponse } from '@/utils/response.js';
|
||||
import { ERROR_CODES, type GetLogsResponse } from '@insforge/shared-schemas';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All logs routes require admin authentication
|
||||
router.use(verifyAdmin);
|
||||
|
||||
// GET /logs/audits - List audit logs
|
||||
router.get('/audits', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { limit = 100, offset = 0, actor, action, module, start_date, end_date } = req.query;
|
||||
|
||||
const auditService = AuditService.getInstance();
|
||||
|
||||
// Build query parameters for audit service
|
||||
const queryParams = {
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
...(actor && typeof actor === 'string' && { actor }),
|
||||
...(action && typeof action === 'string' && { action }),
|
||||
...(module && typeof module === 'string' && { module }),
|
||||
...(start_date && typeof start_date === 'string' && { start_date: new Date(start_date) }),
|
||||
...(end_date && typeof end_date === 'string' && { end_date: new Date(end_date) }),
|
||||
};
|
||||
|
||||
// Get audit logs with total count
|
||||
const { records, total } = await auditService.query(queryParams);
|
||||
|
||||
paginatedResponse(res, records, total, Number(offset));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logs/audits/stats - Get audit logs statistics
|
||||
router.get('/audits/stats', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { days = 7 } = req.query;
|
||||
|
||||
const auditService = AuditService.getInstance();
|
||||
const stats = await auditService.getStats(Number(days));
|
||||
|
||||
successResponse(res, stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /logs/audits - Clear audit logs (admin only)
|
||||
router.delete('/audits', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { days_to_keep = 90 } = req.query;
|
||||
|
||||
const auditService = AuditService.getInstance();
|
||||
const deletedCount = await auditService.cleanup(Number(days_to_keep));
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Audit logs cleared successfully',
|
||||
deleted: deletedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// System logs routes
|
||||
// GET /logs/sources - List all log sources
|
||||
router.get('/sources', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = LogService.getInstance();
|
||||
const sources = await logService.getLogSources();
|
||||
|
||||
successResponse(res, sources);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logs/stats - Get statistics for all log sources
|
||||
router.get('/stats', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = LogService.getInstance();
|
||||
const stats = await logService.getLogSourceStats();
|
||||
|
||||
successResponse(res, stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logs/functions/build-logs - Get function build logs from Deno Deploy
|
||||
router.get('/functions/build-logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { deployment_id } = req.query;
|
||||
|
||||
const logService = LogService.getInstance();
|
||||
const result = await logService.getBuildLogs(deployment_id as string | undefined);
|
||||
|
||||
if (!result) {
|
||||
throw new AppError(
|
||||
'Build logs not available. Deno Deploy may not be configured or no deployments found.',
|
||||
404,
|
||||
ERROR_CODES.LOG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logs/search - Search across all logs or specific source
|
||||
router.get('/search', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { q, source, limit = 100, offset = 0 } = req.query;
|
||||
|
||||
if (!q || typeof q !== 'string') {
|
||||
throw new AppError('Search query parameter (q) is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const logService = LogService.getInstance();
|
||||
const result = await logService.searchLogs(
|
||||
q,
|
||||
source as string | undefined,
|
||||
Number(limit),
|
||||
Number(offset)
|
||||
);
|
||||
|
||||
paginatedResponse(res, result.logs, result.total, Number(offset));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /logs/:source - Get logs from specific source
|
||||
router.get('/:source', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { source } = req.params;
|
||||
const { limit = 100, before_timestamp } = req.query;
|
||||
|
||||
const logService = LogService.getInstance();
|
||||
const result = await logService.getLogsBySource(
|
||||
source,
|
||||
Number(limit),
|
||||
before_timestamp as string | undefined
|
||||
);
|
||||
|
||||
const response: GetLogsResponse = {
|
||||
logs: result.logs,
|
||||
total: result.total,
|
||||
};
|
||||
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as logsRouter };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest, verifyApiKey } from '@/api/middlewares/auth.js';
|
||||
import { MemoryService } from '@/services/memory/memory.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
rememberRequestSchema,
|
||||
recallRequestSchema,
|
||||
memoryIndexRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const memoryService = MemoryService.getInstance();
|
||||
|
||||
// Memory is a platform-managed primitive; callers authenticate with the
|
||||
// project API key (the CLI / agent), same as other system routes.
|
||||
router.use(verifyApiKey);
|
||||
|
||||
// POST /api/memory/remember
|
||||
router.post('/remember', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsed = rememberRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${parsed.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const results = await memoryService.remember(parsed.data);
|
||||
successResponse(res, { results });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/memory/recall
|
||||
router.post('/recall', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsed = recallRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${parsed.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const memories = await memoryService.recall(parsed.data);
|
||||
successResponse(res, { memories });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/memory/index (cheap title-only listing — the always-load tier)
|
||||
router.post('/index', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsed = memoryIndexRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError(
|
||||
`Validation error: ${parsed.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const entries = await memoryService.index(parsed.data.scope);
|
||||
successResponse(res, { entries });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as memoryRouter };
|
||||
@@ -0,0 +1,209 @@
|
||||
import { z } from 'zod';
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { DatabaseAdvanceService } from '@/services/database/database-advance.service.js';
|
||||
import { AuthService } from '@/services/auth/auth.service.js';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { FunctionService } from '@/services/functions/function.service.js';
|
||||
import { RealtimeChannelService } from '@/services/realtime/realtime-channel.service.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
type AnonKeyResponse,
|
||||
type ProjectIdResponse,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { SecretService } from '@/services/secrets/secret.service.js';
|
||||
import { DatabaseManager } from '@/infra/database/database.manager.js';
|
||||
import { CloudDatabaseProvider } from '@/providers/database/cloud.provider.js';
|
||||
import { MetadataService } from '@/services/metadata/metadata.service.js';
|
||||
|
||||
const router = Router();
|
||||
const authService = AuthService.getInstance();
|
||||
const storageService = StorageService.getInstance();
|
||||
const functionService = FunctionService.getInstance();
|
||||
const realtimeChannelService = RealtimeChannelService.getInstance();
|
||||
const dbManager = DatabaseManager.getInstance();
|
||||
const dbAdvanceService = DatabaseAdvanceService.getInstance();
|
||||
const metadataService = MetadataService.getInstance();
|
||||
|
||||
router.use(verifyAdmin);
|
||||
|
||||
const metadataQuerySchema = z.object({
|
||||
format: z.enum(['json', 'markdown']).optional().default('json'),
|
||||
});
|
||||
|
||||
// Get full metadata (default endpoint)
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const queryValidation = metadataQuerySchema.safeParse(req.query);
|
||||
if (!queryValidation.success) {
|
||||
throw new AppError('Invalid format parameter', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
const { format } = queryValidation.data;
|
||||
|
||||
const metadata = await metadataService.getAppMetadata();
|
||||
|
||||
if (format === 'markdown') {
|
||||
res
|
||||
.set('Content-Type', 'text/markdown; charset=utf-8')
|
||||
.send(metadataService.formatAsMarkdown(metadata));
|
||||
} else {
|
||||
successResponse(res, metadata);
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get auth metadata
|
||||
router.get('/auth', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authMetadata = await authService.getMetadata();
|
||||
successResponse(res, authMetadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get database metadata
|
||||
router.get('/database', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const databaseMetadata = await dbManager.getMetadata();
|
||||
successResponse(res, databaseMetadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get storage metadata
|
||||
router.get('/storage', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const storageMetadata = await storageService.getMetadata();
|
||||
successResponse(res, storageMetadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get functions metadata
|
||||
router.get('/functions', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const functionsMetadata = await functionService.getMetadata();
|
||||
successResponse(res, functionsMetadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get realtime metadata
|
||||
router.get('/realtime', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const realtimeMetadata = await realtimeChannelService.getMetadata();
|
||||
successResponse(res, realtimeMetadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get API key (admin only)
|
||||
router.get('/api-key', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const secretService = SecretService.getInstance();
|
||||
const apiKey = await secretService.getSecretByKey('API_KEY');
|
||||
|
||||
successResponse(res, { apiKey: apiKey });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get anon key (admin only)
|
||||
// Opaque, non-secret client identifier (`anon_...`) that maps requests to the
|
||||
// `anon` role. Safe to embed in frontend bundles; RLS is the security boundary.
|
||||
// Seeded at startup by seedBackend(), so it always exists here.
|
||||
router.get('/anon-key', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const secretService = SecretService.getInstance();
|
||||
const anonKey = await secretService.getSecretByKey('ANON_KEY');
|
||||
|
||||
if (!anonKey) {
|
||||
throw new AppError('Anon key not initialized', 404, ERROR_CODES.SECRET_NOT_FOUND);
|
||||
}
|
||||
|
||||
const response: AnonKeyResponse = { anonKey };
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get backend project id from environment (admin only)
|
||||
router.get('/project-id', (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const projectIdResponse: ProjectIdResponse = {
|
||||
projectId: process.env.PROJECT_ID || null,
|
||||
};
|
||||
successResponse(res, projectIdResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get database connection string from cloud backend (admin only)
|
||||
router.get(
|
||||
'/database-connection-string',
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cloudDbProvider = CloudDatabaseProvider.getInstance();
|
||||
const connectionInfo = await cloudDbProvider.getDatabaseConnectionString();
|
||||
successResponse(res, connectionInfo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get database password from cloud backend (admin only)
|
||||
router.get('/database-password', async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cloudDbProvider = CloudDatabaseProvider.getInstance();
|
||||
const passwordInfo = await cloudDbProvider.getDatabasePassword();
|
||||
successResponse(res, passwordInfo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// get metadata for a table.
|
||||
// Notice: must be after fixed endpoints like /api-key, /anon-key, and /project-id in case of conflict.
|
||||
router.get('/:tableName', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
if (!tableName) {
|
||||
throw new AppError('Table name is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const includeData = false;
|
||||
const includeFunctions = false;
|
||||
const includeSequences = false;
|
||||
const includeViews = false;
|
||||
const schemaResponse = await dbAdvanceService.exportDatabase(
|
||||
[tableName],
|
||||
'json',
|
||||
includeData,
|
||||
includeFunctions,
|
||||
includeSequences,
|
||||
includeViews
|
||||
);
|
||||
|
||||
// When format is 'json', the data contains the tables object
|
||||
const jsonData = schemaResponse.data as { tables: Record<string, unknown> };
|
||||
const metadata = jsonData.tables;
|
||||
successResponse(res, metadata);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as metadataRouter };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { razorpayRouter } from './razorpay/index.routes.js';
|
||||
import { stripeRouter } from './stripe/index.routes.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/stripe', stripeRouter);
|
||||
router.use('/razorpay', razorpayRouter);
|
||||
|
||||
export { router as paymentsRouter };
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Router, type Response, type NextFunction } from 'express';
|
||||
import { normalizeRazorpayError } from '@/providers/payments/razorpay-errors.js';
|
||||
import { type AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { RazorpayCatalogService } from '@/services/payments/razorpay/catalog.service.js';
|
||||
import { getPaymentEnvironment } from '@/services/payments/helpers.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import {
|
||||
createRazorpayItemBodySchema,
|
||||
createRazorpayPlanBodySchema,
|
||||
razorpayItemParamsSchema,
|
||||
updateRazorpayItemBodySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const catalogService = RazorpayCatalogService.getInstance();
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const catalog = await catalogService.listCatalog(environment);
|
||||
successResponse(res, catalog);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/items', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createRazorpayItemBodySchema, req.body);
|
||||
const item = await catalogService.createItem({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, item, 201);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/items/:itemId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const params = parseZodSchema(razorpayItemParamsSchema, req.params);
|
||||
const body = parseZodSchema(updateRazorpayItemBodySchema, req.body);
|
||||
const item = await catalogService.updateItem(params.itemId, {
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, item);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createRazorpayPlanBodySchema, req.body);
|
||||
const plan = await catalogService.createPlan({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, plan, 201);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
export { router as razorpayCatalogRouter };
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router, type Response, type NextFunction } from 'express';
|
||||
import { normalizeRazorpayError } from '@/providers/payments/razorpay-errors.js';
|
||||
import { type AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { RazorpayConfigService } from '@/services/payments/razorpay/config.service.js';
|
||||
import { RazorpaySyncService } from '@/services/payments/razorpay/sync.service.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
razorpayEnvironmentParamsSchema,
|
||||
upsertRazorpayConfigBodySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const configService = RazorpayConfigService.getInstance();
|
||||
const syncService = RazorpaySyncService.getInstance();
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayEnvironmentParamsSchema, req.params);
|
||||
const body = parseZodSchema(upsertRazorpayConfigBodySchema, req.body);
|
||||
|
||||
await configService.setRazorpayKeys(
|
||||
environment,
|
||||
body.keyId,
|
||||
body.keySecret,
|
||||
body.webhookSecret,
|
||||
async (syncEnvironment, provider) => {
|
||||
await syncService.syncEnvironmentAfterKeyChange(syncEnvironment, provider);
|
||||
}
|
||||
);
|
||||
|
||||
const keys = await configService.getKeyConfig();
|
||||
successResponse(res, { keys });
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayEnvironmentParamsSchema, req.params);
|
||||
const removed = await configService.removeRazorpayKeys(environment);
|
||||
if (!removed) {
|
||||
throw new AppError(
|
||||
'No Razorpay keys configured for this environment',
|
||||
404,
|
||||
ERROR_CODES.PAYMENT_CONFIG_NOT_FOUND
|
||||
);
|
||||
}
|
||||
const keys = await configService.getKeyConfig();
|
||||
successResponse(res, { keys });
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sync', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayEnvironmentParamsSchema, req.params);
|
||||
const result = await syncService.syncAll(environment);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/webhook', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayEnvironmentParamsSchema, req.params);
|
||||
const result = await configService.getWebhookSetup(environment);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/webhook/rotate-secret',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayEnvironmentParamsSchema, req.params);
|
||||
const result = await configService.rotateWebhookSecret(environment);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as razorpayConfigRouter };
|
||||
@@ -0,0 +1,310 @@
|
||||
import { Router, type Response, type NextFunction } from 'express';
|
||||
import { normalizeRazorpayError } from '@/providers/payments/razorpay-errors.js';
|
||||
import { verifyAdmin, verifyUser, type AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { RazorpayConfigService } from '@/services/payments/razorpay/config.service.js';
|
||||
import { RazorpaySyncService } from '@/services/payments/razorpay/sync.service.js';
|
||||
import { RazorpayOrderService } from '@/services/payments/razorpay/order.service.js';
|
||||
import { RazorpaySubscriptionService } from '@/services/payments/razorpay/subscription.service.js';
|
||||
import { PaymentCustomerService } from '@/services/payments/payment-customer.service.js';
|
||||
import { PaymentTransactionService } from '@/services/payments/transaction.service.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { getPaymentEnvironment } from '@/services/payments/helpers.js';
|
||||
import { razorpayCatalogRouter } from './catalog.routes.js';
|
||||
import { razorpayConfigRouter } from './config.routes.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
cancelRazorpaySubscriptionBodySchema,
|
||||
createRazorpayOrderBodySchema,
|
||||
createRazorpaySubscriptionBodySchema,
|
||||
listPaymentCustomersQuerySchema,
|
||||
listPaymentTransactionsQuerySchema,
|
||||
listRazorpaySubscriptionsQuerySchema,
|
||||
pauseRazorpaySubscriptionBodySchema,
|
||||
razorpaySubscriptionParamsSchema,
|
||||
resumeRazorpaySubscriptionBodySchema,
|
||||
verifyRazorpayOrderBodySchema,
|
||||
verifyRazorpaySubscriptionBodySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const environmentRouter = Router({ mergeParams: true });
|
||||
const configService = RazorpayConfigService.getInstance();
|
||||
const syncService = RazorpaySyncService.getInstance();
|
||||
const orderService = RazorpayOrderService.getInstance();
|
||||
const subscriptionService = RazorpaySubscriptionService.getInstance();
|
||||
const customerService = PaymentCustomerService.getInstance();
|
||||
const transactionService = PaymentTransactionService.getInstance();
|
||||
|
||||
router.get('/status', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const connections = await configService.getRazorpayStatus();
|
||||
successResponse(res, { razorpayConnections: connections });
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keys = await configService.getKeyConfig();
|
||||
successResponse(res, { keys });
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sync', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await syncService.syncAll('all');
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
environmentRouter.post(
|
||||
'/orders',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createRazorpayOrderBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay order creation requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const order = await orderService.createOrder(
|
||||
{
|
||||
environment,
|
||||
...body,
|
||||
},
|
||||
req.user
|
||||
);
|
||||
successResponse(res, order, 201);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/orders/verify',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(verifyRazorpayOrderBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay order verification requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const result = await orderService.verifyOrderPayment({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/subscriptions',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createRazorpaySubscriptionBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay subscription creation requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await subscriptionService.createSubscription(
|
||||
{
|
||||
environment,
|
||||
...body,
|
||||
},
|
||||
req.user
|
||||
);
|
||||
successResponse(res, subscription, 201);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/subscriptions/verify',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(verifyRazorpaySubscriptionBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay subscription verification requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const result = await subscriptionService.verifySubscriptionPayment({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/subscriptions/:subscriptionId/cancel',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(razorpaySubscriptionParamsSchema, req.params);
|
||||
const body = parseZodSchema(cancelRazorpaySubscriptionBodySchema, req.body ?? {});
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay subscription cancellation requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const result = await subscriptionService.cancelSubscription(
|
||||
{
|
||||
...params,
|
||||
...body,
|
||||
},
|
||||
req.user
|
||||
);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/subscriptions/:subscriptionId/pause',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(razorpaySubscriptionParamsSchema, req.params);
|
||||
parseZodSchema(pauseRazorpaySubscriptionBodySchema, req.body ?? {});
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay subscription pause requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const result = await subscriptionService.pauseSubscription(params, req.user);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/subscriptions/:subscriptionId/resume',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(razorpaySubscriptionParamsSchema, req.params);
|
||||
parseZodSchema(resumeRazorpaySubscriptionBodySchema, req.body ?? {});
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Razorpay subscription resume requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const result = await subscriptionService.resumeSubscription(params, req.user);
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.use(verifyAdmin);
|
||||
environmentRouter.use(razorpayConfigRouter);
|
||||
environmentRouter.use('/catalog', razorpayCatalogRouter);
|
||||
|
||||
environmentRouter.get('/customers', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listPaymentCustomersQuerySchema, req.query);
|
||||
const customers = await customerService.listCustomers({ environment, ...query }, 'razorpay');
|
||||
successResponse(res, customers);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
});
|
||||
|
||||
environmentRouter.get(
|
||||
'/transactions',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listPaymentTransactionsQuerySchema, req.query);
|
||||
const transactions = await transactionService.listTransactions(
|
||||
{
|
||||
environment,
|
||||
...query,
|
||||
},
|
||||
'razorpay'
|
||||
);
|
||||
successResponse(res, transactions);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.get(
|
||||
'/subscriptions',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listRazorpaySubscriptionsQuerySchema, req.query);
|
||||
const subscriptions = await subscriptionService.listSubscriptions({ environment, ...query });
|
||||
successResponse(res, subscriptions);
|
||||
} catch (error) {
|
||||
next(normalizeRazorpayError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.use('/:environment', environmentRouter);
|
||||
|
||||
export { router as razorpayRouter };
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { StripeProductService } from '@/services/payments/stripe/product.service.js';
|
||||
import { StripePriceService } from '@/services/payments/stripe/price.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { getPaymentEnvironment } from '@/services/payments/helpers.js';
|
||||
import { normalizeStripeError } from '@/providers/payments/stripe-errors.js';
|
||||
import {
|
||||
createStripePriceBodySchema,
|
||||
createStripeProductBodySchema,
|
||||
listStripePricesQuerySchema,
|
||||
stripePriceParamsSchema,
|
||||
stripeProductParamsSchema,
|
||||
updateStripePriceBodySchema,
|
||||
updateStripeProductBodySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const productService = StripeProductService.getInstance();
|
||||
const priceService = StripePriceService.getInstance();
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const [products, prices] = await Promise.all([
|
||||
productService.listProducts({ environment }),
|
||||
priceService.listPrices({ environment }),
|
||||
]);
|
||||
const catalog = {
|
||||
products: products.products,
|
||||
prices: prices.prices,
|
||||
};
|
||||
successResponse(res, catalog);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/products', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const products = await productService.listProducts({ environment });
|
||||
successResponse(res, products);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/products/:productId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const params = parseZodSchema(stripeProductParamsSchema, req.params);
|
||||
|
||||
const product = await productService.getProduct(environment, params.productId);
|
||||
successResponse(res, product);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/products', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createStripeProductBodySchema, req.body);
|
||||
|
||||
const product = await productService.createProduct({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, product, 201);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.patch(
|
||||
'/products/:productId',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(stripeProductParamsSchema, req.params);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(updateStripeProductBodySchema, req.body);
|
||||
|
||||
const product = await productService.updateProduct(params.productId, {
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, product);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/products/:productId',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(stripeProductParamsSchema, req.params);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const product = await productService.deleteProduct(environment, params.productId);
|
||||
successResponse(res, product);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/prices', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const query = parseZodSchema(listStripePricesQuerySchema, req.query);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const prices = await priceService.listPrices({
|
||||
environment,
|
||||
...query,
|
||||
});
|
||||
successResponse(res, prices);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/prices/:priceId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(stripePriceParamsSchema, req.params);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const price = await priceService.getPrice(environment, params.priceId);
|
||||
successResponse(res, price);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/prices', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = parseZodSchema(createStripePriceBodySchema, req.body);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const price = await priceService.createPrice({
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, price, 201);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/prices/:priceId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(stripePriceParamsSchema, req.params);
|
||||
|
||||
const body = parseZodSchema(updateStripePriceBodySchema, req.body);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const price = await priceService.updatePrice(params.priceId, {
|
||||
environment,
|
||||
...body,
|
||||
});
|
||||
successResponse(res, price);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/prices/:priceId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const params = parseZodSchema(stripePriceParamsSchema, req.params);
|
||||
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const price = await priceService.archivePrice(environment, params.priceId);
|
||||
successResponse(res, price);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
export { router as stripeCatalogRouter };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { StripeConfigService } from '@/services/payments/stripe/config.service.js';
|
||||
import { StripeSyncService } from '@/services/payments/stripe/sync.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { getPaymentEnvironment } from '@/services/payments/helpers.js';
|
||||
import { normalizeStripeError } from '@/providers/payments/stripe-errors.js';
|
||||
import { ERROR_CODES, upsertStripeConfigBodySchema } from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
const configService = StripeConfigService.getInstance();
|
||||
const syncService = StripeSyncService.getInstance();
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(upsertStripeConfigBodySchema, req.body);
|
||||
|
||||
await configService.setStripeSecretKey(
|
||||
environment,
|
||||
body.secretKey,
|
||||
async (syncEnvironment, provider) => {
|
||||
await syncService.syncPaymentsEnvironmentAfterKeyChange(syncEnvironment, provider);
|
||||
}
|
||||
);
|
||||
|
||||
const config = await configService.getConfig();
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const removed = await configService.removeStripeSecretKey(environment);
|
||||
if (!removed) {
|
||||
throw new AppError('No Stripe key configured', 404, ERROR_CODES.PAYMENT_CONFIG_NOT_FOUND);
|
||||
}
|
||||
|
||||
const config = await configService.getConfig();
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sync', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const result = await syncService.syncPayments({ environment });
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/webhook', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const connection = await configService.configureManagedStripeWebhook(environment);
|
||||
const result = { connection };
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
export { router as stripeConfigRouter };
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest, verifyAdmin, verifyUser } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { StripeConfigService } from '@/services/payments/stripe/config.service.js';
|
||||
import { StripeSyncService } from '@/services/payments/stripe/sync.service.js';
|
||||
import { StripeCheckoutService } from '@/services/payments/stripe/checkout.service.js';
|
||||
import { StripeCustomerPortalService } from '@/services/payments/stripe/customer-portal.service.js';
|
||||
import { StripeSubscriptionService } from '@/services/payments/stripe/subscription.service.js';
|
||||
import { PaymentCustomerService } from '@/services/payments/payment-customer.service.js';
|
||||
import { PaymentTransactionService } from '@/services/payments/transaction.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { getPaymentEnvironment } from '@/services/payments/helpers.js';
|
||||
import { stripeCatalogRouter } from './catalog.routes.js';
|
||||
import { stripeConfigRouter } from './config.routes.js';
|
||||
import { normalizeStripeError } from '@/providers/payments/stripe-errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createCheckoutSessionBodySchema,
|
||||
createCustomerPortalSessionBodySchema,
|
||||
listPaymentCustomersQuerySchema,
|
||||
listPaymentTransactionsQuerySchema,
|
||||
listStripeSubscriptionsQuerySchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const environmentRouter = Router({ mergeParams: true });
|
||||
const configService = StripeConfigService.getInstance();
|
||||
const syncService = StripeSyncService.getInstance();
|
||||
const checkoutService = StripeCheckoutService.getInstance();
|
||||
const customerPortalService = StripeCustomerPortalService.getInstance();
|
||||
const subscriptionService = StripeSubscriptionService.getInstance();
|
||||
const customerService = PaymentCustomerService.getInstance();
|
||||
const transactionService = PaymentTransactionService.getInstance();
|
||||
|
||||
router.get('/status', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = await configService.getStatus();
|
||||
successResponse(res, status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = await configService.getConfig();
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sync', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await syncService.syncPayments({ environment: 'all' });
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
});
|
||||
|
||||
environmentRouter.post(
|
||||
'/checkout-sessions',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createCheckoutSessionBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Checkout session creation requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const checkoutSession = await checkoutService.createCheckoutSession(
|
||||
{
|
||||
environment,
|
||||
...body,
|
||||
},
|
||||
req.user
|
||||
);
|
||||
successResponse(res, checkoutSession, 201);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.post(
|
||||
'/customer-portal-sessions',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const body = parseZodSchema(createCustomerPortalSessionBodySchema, req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(
|
||||
'Customer portal session creation requires a user token',
|
||||
401,
|
||||
ERROR_CODES.AUTH_INVALID_CREDENTIALS
|
||||
);
|
||||
}
|
||||
|
||||
const customerPortalSession = await customerPortalService.createCustomerPortalSession(
|
||||
{
|
||||
environment,
|
||||
...body,
|
||||
},
|
||||
req.user
|
||||
);
|
||||
successResponse(res, customerPortalSession, 201);
|
||||
} catch (error) {
|
||||
next(normalizeStripeError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.use(verifyAdmin);
|
||||
environmentRouter.use(stripeConfigRouter);
|
||||
environmentRouter.use('/catalog', stripeCatalogRouter);
|
||||
|
||||
environmentRouter.get(
|
||||
'/transactions',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listPaymentTransactionsQuerySchema, req.query);
|
||||
|
||||
const transactions = await transactionService.listTransactions(
|
||||
{
|
||||
environment,
|
||||
...query,
|
||||
},
|
||||
'stripe'
|
||||
);
|
||||
successResponse(res, transactions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.get(
|
||||
'/subscriptions',
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listStripeSubscriptionsQuerySchema, req.query);
|
||||
|
||||
const subscriptions = await subscriptionService.listSubscriptions({
|
||||
environment,
|
||||
...query,
|
||||
});
|
||||
successResponse(res, subscriptions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
environmentRouter.get('/customers', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const environment = getPaymentEnvironment(req.params);
|
||||
const query = parseZodSchema(listPaymentCustomersQuerySchema, req.query);
|
||||
|
||||
const customers = await customerService.listCustomers({
|
||||
environment,
|
||||
...query,
|
||||
});
|
||||
successResponse(res, customers);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.use('/:environment', environmentRouter);
|
||||
|
||||
export { router as stripeRouter };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { RealtimeChannelService } from '@/services/realtime/realtime-channel.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createChannelRequestSchema,
|
||||
updateChannelRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const channelService = RealtimeChannelService.getInstance();
|
||||
|
||||
// List all channels
|
||||
router.get('/', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const channels = await channelService.list();
|
||||
successResponse(res, channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get channel by ID
|
||||
router.get('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const channel = await channelService.getById(req.params.id);
|
||||
if (!channel) {
|
||||
throw new AppError('Channel not found', 404, ERROR_CODES.REALTIME_CHANNEL_NOT_FOUND);
|
||||
}
|
||||
successResponse(res, channel);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a channel
|
||||
router.post('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createChannelRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.REALTIME_INVALID_CHANNEL_REQUEST
|
||||
);
|
||||
}
|
||||
const channel = await channelService.create(validation.data);
|
||||
successResponse(res, channel, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update a channel
|
||||
router.put('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = updateChannelRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.REALTIME_INVALID_CHANNEL_REQUEST
|
||||
);
|
||||
}
|
||||
const channel = await channelService.update(req.params.id, validation.data);
|
||||
successResponse(res, channel);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a channel
|
||||
router.delete('/:id', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await channelService.delete(req.params.id);
|
||||
successResponse(res, { message: 'Channel deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as channelsRouter };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { channelsRouter } from './channels.routes.js';
|
||||
import { messagesRouter } from './messages.routes.js';
|
||||
import { permissionsRouter } from './permissions.routes.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { RealtimeMessageService } from '@/services/realtime/realtime-message.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
getRealtimeConfigResponseSchema,
|
||||
updateRealtimeConfigRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const messageService = RealtimeMessageService.getInstance();
|
||||
|
||||
router.use('/channels', channelsRouter);
|
||||
router.use('/messages', messagesRouter);
|
||||
router.use('/permissions', permissionsRouter);
|
||||
|
||||
// Get realtime config
|
||||
router.get('/config', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = getRealtimeConfigResponseSchema.parse({
|
||||
retentionDays: await messageService.getRetentionDays(),
|
||||
});
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update realtime config
|
||||
router.patch(
|
||||
'/config',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = updateRealtimeConfigRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { retentionDays } = validation.data;
|
||||
await messageService.updateRetentionDays(retentionDays);
|
||||
successResponse(res, { message: 'Retention config updated successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as realtimeRouter };
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { RealtimeMessageService } from '@/services/realtime/realtime-message.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
clearRealtimeMessagesResponseSchema,
|
||||
ERROR_CODES,
|
||||
listMessagesRequestSchema,
|
||||
messageStatsRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const messageService = RealtimeMessageService.getInstance();
|
||||
|
||||
// List messages
|
||||
router.get('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = listMessagesRequestSchema.safeParse(req.query);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const messages = await messageService.list(validation.data);
|
||||
successResponse(res, messages);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all messages
|
||||
router.delete('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const deleted = await messageService.clearAllMessages();
|
||||
const response = clearRealtimeMessagesResponseSchema.parse({ deleted });
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get message statistics
|
||||
router.get('/stats', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = messageStatsRequestSchema.safeParse(req.query);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
const stats = await messageService.getStats(validation.data);
|
||||
successResponse(res, stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as messagesRouter };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { RealtimeChannelService } from '@/services/realtime/realtime-channel.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
|
||||
const router = Router();
|
||||
const channelService = RealtimeChannelService.getInstance();
|
||||
|
||||
// Get realtime RLS permissions (subscribe on channels, publish on messages)
|
||||
router.get('/', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const permissions = await channelService.getPermissions();
|
||||
successResponse(res, permissions);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as permissionsRouter };
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const uploadIdRaw = req.query.uploadId;
|
||||
const uploadId = typeof uploadIdRaw === 'string' ? uploadIdRaw : '';
|
||||
if (!uploadId) {
|
||||
sendS3Error(res, 'InvalidRequest', 'Missing uploadId', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await StorageService.getInstance().getProvider().abortMultipartUpload(bucket, key, uploadId);
|
||||
res.status(204).send();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { parseXml, toXml } from '../xml.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
const MAX_COMPLETE_MPU_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
const partSchema = z.object({
|
||||
PartNumber: z.coerce.number().int().positive().max(10_000),
|
||||
ETag: z.string().min(1),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
CompleteMultipartUpload: z.object({
|
||||
Part: z.union([partSchema, z.array(partSchema).nonempty()]),
|
||||
}),
|
||||
});
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const uploadIdRaw = req.query.uploadId;
|
||||
const uploadId = typeof uploadIdRaw === 'string' ? uploadIdRaw : '';
|
||||
if (!uploadId) {
|
||||
sendS3Error(res, 'InvalidRequest', 'Missing uploadId', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
for await (const c of req) {
|
||||
const b = c as Buffer;
|
||||
received += b.length;
|
||||
if (received > MAX_COMPLETE_MPU_BODY_BYTES) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'EntityTooLarge',
|
||||
'CompleteMultipartUpload body exceeds maximum allowed size',
|
||||
{
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(b);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseXml(Buffer.concat(chunks));
|
||||
} catch {
|
||||
sendS3Error(res, 'MalformedXML', 'Request body is not valid XML', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = bodySchema.safeParse(parsed);
|
||||
if (!validation.success) {
|
||||
sendS3Error(res, 'InvalidPart', validation.error.issues.map((e) => e.message).join('; '), {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const partEntry = validation.data.CompleteMultipartUpload.Part;
|
||||
const partsArr = Array.isArray(partEntry) ? partEntry : [partEntry];
|
||||
const parts = partsArr.map((p) => ({
|
||||
partNumber: p.PartNumber,
|
||||
etag: p.ETag.replace(/^"(.*)"$/, '$1'),
|
||||
}));
|
||||
|
||||
const svc = StorageService.getInstance();
|
||||
const { etag, size } = await svc
|
||||
.getProvider()
|
||||
.completeMultipartUpload(bucket, key, uploadId, parts);
|
||||
|
||||
await svc.upsertS3Object({
|
||||
bucket,
|
||||
key,
|
||||
size,
|
||||
etag,
|
||||
contentType: null,
|
||||
s3AccessKeyId: req.s3Auth.accessKeyId,
|
||||
});
|
||||
|
||||
const xml = toXml({
|
||||
CompleteMultipartUploadResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Location: `${req.protocol}://${req.headers.host}${req.path}`,
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ETag: `"${etag}"`,
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const dstBucket = getS3Bucket(req);
|
||||
const dstKey = getS3Key(req);
|
||||
|
||||
const source = req.headers['x-amz-copy-source'] as string | undefined;
|
||||
if (!source) {
|
||||
sendS3Error(res, 'InvalidRequest', 'Missing x-amz-copy-source', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(source.replace(/^\//, ''));
|
||||
} catch {
|
||||
sendS3Error(res, 'InvalidRequest', 'Malformed x-amz-copy-source', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const slash = decoded.indexOf('/');
|
||||
if (slash === -1) {
|
||||
sendS3Error(res, 'InvalidRequest', 'Malformed x-amz-copy-source', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const srcBucket = decoded.slice(0, slash);
|
||||
const srcKey = decoded.slice(slash + 1);
|
||||
|
||||
const svc = StorageService.getInstance();
|
||||
const result = await svc.getProvider().copyObject(srcBucket, srcKey, dstBucket, dstKey);
|
||||
|
||||
// Read metadata from the destination after the copy, not from the source —
|
||||
// the source may have been mutated or deleted between copy and head. If
|
||||
// the destination head comes back null after a reported-successful copy,
|
||||
// something is wrong with the backend or cross-region consistency and we
|
||||
// should NOT persist a size:0 row.
|
||||
const head = await svc.getProvider().headObject(dstBucket, dstKey);
|
||||
if (!head) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InternalError',
|
||||
`CopyObject succeeded but destination ${dstBucket}/${dstKey} is not visible`,
|
||||
{ resource: req.path, requestId: req.s3Auth.requestId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await svc.upsertS3Object({
|
||||
bucket: dstBucket,
|
||||
key: dstKey,
|
||||
size: head.size,
|
||||
etag: result.etag,
|
||||
contentType: head.contentType ?? null,
|
||||
s3AccessKeyId: req.s3Auth.accessKeyId,
|
||||
});
|
||||
|
||||
const xml = toXml({
|
||||
CopyObjectResult: {
|
||||
ETag: `"${result.etag}"`,
|
||||
LastModified: result.lastModified.toISOString(),
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
const BUCKET_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
if (!BUCKET_NAME_RE.test(bucket)) {
|
||||
sendS3Error(res, 'InvalidBucketName', `Invalid bucket name ${bucket}`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const svc = StorageService.getInstance();
|
||||
if (await svc.bucketExists(bucket)) {
|
||||
sendS3Error(res, 'BucketAlreadyOwnedByYou', `Bucket ${bucket} already exists`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await svc.createBucket(bucket, false);
|
||||
res.status(200).set('Location', `/${bucket}`).send();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const contentType = (req.headers['content-type'] as string) ?? 'application/octet-stream';
|
||||
const { uploadId } = await StorageService.getInstance()
|
||||
.getProvider()
|
||||
.createMultipartUpload(bucket, key, { contentType });
|
||||
const xml = toXml({
|
||||
InitiateMultipartUploadResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
await svc.deleteBucketCorsRules(bucket);
|
||||
res.status(204).send();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
sendS3Error(res, 'NoSuchBucket', `Bucket ${bucket} does not exist`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(await svc.bucketIsEmpty(bucket))) {
|
||||
sendS3Error(res, 'BucketNotEmpty', `Bucket ${bucket} is not empty`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await svc.deleteBucket(bucket);
|
||||
res.status(204).send();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
if (!(await svc.getObjectMetadataRow(bucket, key))) {
|
||||
throw new S3ProtocolError('NoSuchKey', `The specified key does not exist: ${key}`);
|
||||
}
|
||||
|
||||
await svc.deleteObjectTags(bucket, key);
|
||||
res.status(204).send();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
const e = err as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
|
||||
return (
|
||||
e?.name === 'NoSuchKey' ||
|
||||
e?.Code === 'NoSuchKey' ||
|
||||
e?.name === 'NotFound' ||
|
||||
e?.$metadata?.httpStatusCode === 404
|
||||
);
|
||||
}
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
// S3 DeleteObject is idempotent for missing keys. Real provider failures
|
||||
// (network, permission, etc.) must propagate so the outer handler maps them
|
||||
// to a proper S3 error — otherwise we'd return 204 AND drop the DB row
|
||||
// while the object is still in the bucket.
|
||||
try {
|
||||
await svc.getProvider().deleteObject(bucket, key);
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await svc.deleteObjectRow(bucket, key);
|
||||
res.status(204).send();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { parseXml, toXml } from '../xml.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
interface ParsedDelete {
|
||||
Delete?: {
|
||||
Object?: { Key?: string } | Array<{ Key?: string }>;
|
||||
Quiet?: string | boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
const e = err as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
|
||||
return (
|
||||
e?.name === 'NoSuchKey' ||
|
||||
e?.Code === 'NoSuchKey' ||
|
||||
e?.name === 'NotFound' ||
|
||||
e?.$metadata?.httpStatusCode === 404
|
||||
);
|
||||
}
|
||||
|
||||
// AWS caps DeleteObjects at 1000 keys per request. 1 MiB of request XML is
|
||||
// plenty of headroom for that while preventing a client from streaming an
|
||||
// unbounded body into memory before we can reject it.
|
||||
const MAX_DELETE_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
for await (const c of req) {
|
||||
const b = c as Buffer;
|
||||
received += b.length;
|
||||
if (received > MAX_DELETE_BODY_BYTES) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'EntityTooLarge',
|
||||
`Delete request body exceeds ${MAX_DELETE_BODY_BYTES} bytes`,
|
||||
{
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
}
|
||||
);
|
||||
req.unpipe?.();
|
||||
req.destroy?.();
|
||||
return;
|
||||
}
|
||||
chunks.push(b);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
|
||||
let parsed: ParsedDelete;
|
||||
try {
|
||||
parsed = (await parseXml(body)) as ParsedDelete;
|
||||
} catch {
|
||||
sendS3Error(res, 'MalformedXML', 'Request body is not valid XML', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const delBlock = parsed?.Delete ?? {};
|
||||
const quiet = delBlock.Quiet === true || delBlock.Quiet === 'true';
|
||||
let items: Array<{ Key?: string }> = [];
|
||||
if (Array.isArray(delBlock.Object)) {
|
||||
items = delBlock.Object;
|
||||
} else if (delBlock.Object) {
|
||||
items = [delBlock.Object];
|
||||
}
|
||||
const keys = items.map((i) => i.Key).filter((k): k is string => !!k);
|
||||
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
const deleted: Array<{ Key: string }> = [];
|
||||
const errors: Array<{ Key: string; Code: string; Message: string }> = [];
|
||||
|
||||
// Delete each object individually and track outcomes per key. Only count
|
||||
// actual deletions (and 404s, per S3 idempotency) as success; provider
|
||||
// failures become <Error> entries and their DB rows are NOT removed.
|
||||
await Promise.all(
|
||||
keys.map(async (k) => {
|
||||
try {
|
||||
await svc.getProvider().deleteObject(bucket, k);
|
||||
deleted.push({ Key: k });
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
deleted.push({ Key: k });
|
||||
} else {
|
||||
errors.push({
|
||||
Key: k,
|
||||
Code: 'InternalError',
|
||||
Message: err instanceof Error ? err.message : 'Delete failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Only delete the DB rows whose provider-side deletes succeeded.
|
||||
const successKeys = deleted.map((d) => d.Key);
|
||||
await svc.deleteObjectRowsBatch(bucket, successKeys);
|
||||
|
||||
const xml = toXml({
|
||||
DeleteResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
...(quiet ? {} : { Deleted: deleted }),
|
||||
...(errors.length ? { Error: errors } : {}),
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
const rules = await svc.getBucketCorsRules(bucket);
|
||||
if (!rules) {
|
||||
throw new S3ProtocolError('NoSuchCORSConfiguration', 'The CORS configuration does not exist');
|
||||
}
|
||||
|
||||
const xml = toXml({
|
||||
CORSConfiguration: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
CORSRule: rules,
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Response } from 'express';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3GatewayRequest } from '../request.js';
|
||||
|
||||
export function handle(_req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
res
|
||||
.status(200)
|
||||
.type('application/xml')
|
||||
.send(
|
||||
toXml({
|
||||
LocationConstraint: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
_: 'us-east-2',
|
||||
},
|
||||
})
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Response } from 'express';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3ProtocolError } from '../errors.js';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
const status = await svc.getBucketVersioningStatus(bucket);
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.type('application/xml')
|
||||
.send(
|
||||
toXml({
|
||||
VersioningConfiguration: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Status: status ?? 'Disabled',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
if (!(await svc.getObjectMetadataRow(bucket, key))) {
|
||||
throw new S3ProtocolError('NoSuchKey', `The specified key does not exist: ${key}`);
|
||||
}
|
||||
|
||||
const tags = await svc.getObjectTags(bucket, key);
|
||||
|
||||
const tagElements = tags.map((t) => ({ Key: t.tagKey, Value: t.tagValue }));
|
||||
const tagSet: Record<string, unknown> =
|
||||
tagElements.length === 0
|
||||
? {}
|
||||
: { Tag: tagElements.length === 1 ? tagElements[0] : tagElements };
|
||||
|
||||
const xml = toXml({
|
||||
Tagging: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
TagSet: tagSet,
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
sendS3Error(res, 'NoSuchBucket', 'Bucket does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Branch DB is the source of truth for "does this object exist in this
|
||||
// branch?". Without this check, the provider's branch→parent S3 fallback
|
||||
// could resurrect an inherited object that the branch already deleted, or
|
||||
// expose a parent-side post-fork upload that this branch never received.
|
||||
// HeadObject already gates on the metadata row; GetObject must match.
|
||||
if (!(await svc.getObjectMetadataRow(bucket, key))) {
|
||||
sendS3Error(res, 'NoSuchKey', 'Object does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const range = req.headers['range'] as string | undefined;
|
||||
try {
|
||||
const result = await svc.getProvider().getObjectStream(bucket, key, { range });
|
||||
res
|
||||
.status(range ? 206 : 200)
|
||||
.set('Content-Length', String(result.size))
|
||||
.set('Content-Type', result.contentType ?? 'application/octet-stream')
|
||||
.set('ETag', `"${result.etag}"`)
|
||||
.set('Last-Modified', result.lastModified.toUTCString())
|
||||
.set('Accept-Ranges', 'bytes');
|
||||
result.body.pipe(res);
|
||||
} catch (err: unknown) {
|
||||
const name = (err as { name?: string; Code?: string }).name ?? (err as { Code?: string }).Code;
|
||||
if (name === 'NoSuchKey' || name === 'NotFound') {
|
||||
sendS3Error(res, 'NoSuchKey', 'Object does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const exists = await StorageService.getInstance().bucketExists(bucket);
|
||||
if (!exists) {
|
||||
sendS3Error(res, 'NoSuchBucket', `Bucket ${bucket} does not exist`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(200).send();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
sendS3Error(res, 'NoSuchBucket', 'Bucket does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const meta = await svc.getObjectMetadataRow(bucket, key);
|
||||
if (!meta) {
|
||||
sendS3Error(res, 'NoSuchKey', 'Object does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res
|
||||
.status(200)
|
||||
.set('Content-Length', String(meta.size))
|
||||
.set('Content-Type', meta.mimeType ?? 'application/octet-stream')
|
||||
.set('ETag', `"${meta.etag ?? ''}"`)
|
||||
.set('Last-Modified', meta.uploadedAt.toUTCString())
|
||||
.set('Accept-Ranges', 'bytes')
|
||||
.send();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3AuthenticatedRequest } from '@/api/middlewares/s3-sigv4.js';
|
||||
|
||||
export async function handle(_req: S3AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const buckets = await StorageService.getInstance().listAllBucketsSimple();
|
||||
const xml = toXml({
|
||||
ListAllMyBucketsResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Owner: { ID: 'insforge', DisplayName: 'insforge' },
|
||||
Buckets: {
|
||||
Bucket: buckets.map((b) => ({
|
||||
Name: b.name,
|
||||
CreationDate: b.createdAt.toISOString(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
const MAX_KEYS_DEFAULT = 1000;
|
||||
const MAX_KEYS_LIMIT = 1000;
|
||||
// With delimiter=/, many raw keys may collapse into a single CommonPrefix.
|
||||
// We fetch the DB in windows and accumulate visible entries until we hit
|
||||
// maxKeys. This cap bounds total DB work per request.
|
||||
const DB_WINDOW = 1000;
|
||||
const MAX_DB_PAGES = 200;
|
||||
|
||||
function encodeContinuation(key: string): string {
|
||||
return Buffer.from(key, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeContinuation(token: string | undefined): string | undefined {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
return Buffer.from(token, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
sendS3Error(res, 'NoSuchBucket', `Bucket ${bucket} does not exist`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const q = req.query as Record<string, string | undefined>;
|
||||
const prefix = q['prefix'] ?? '';
|
||||
const delimiter = q['delimiter'];
|
||||
// Validate max-keys: S3 accepts integers in [0, 1000] and defaults to 1000.
|
||||
// Negative, fractional, or non-numeric values must be rejected before they
|
||||
// produce nonsense like MaxKeys=-5 in the response.
|
||||
const maxKeysRaw = q['max-keys'];
|
||||
let maxKeys: number;
|
||||
if (maxKeysRaw === undefined || maxKeysRaw === '') {
|
||||
maxKeys = MAX_KEYS_DEFAULT;
|
||||
} else {
|
||||
if (!/^\d+$/.test(maxKeysRaw)) {
|
||||
sendS3Error(res, 'InvalidArgument', `max-keys must be a non-negative integer`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parsed = Number(maxKeysRaw);
|
||||
if (parsed > MAX_KEYS_LIMIT) {
|
||||
sendS3Error(res, 'InvalidArgument', `max-keys must be <= ${MAX_KEYS_LIMIT}`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
maxKeys = parsed;
|
||||
}
|
||||
const startAfterInput = q['start-after'] ?? decodeContinuation(q['continuation-token']);
|
||||
|
||||
// Accumulate visible entries (Contents + CommonPrefixes) up to maxKeys.
|
||||
// Track the last DB row key we advanced past for continuation.
|
||||
const contents: Array<{ Key: string; Size: number; ETag: string; LastModified: string }> = [];
|
||||
const commonPrefixesSet = new Set<string>();
|
||||
let cursor: string | undefined = startAfterInput;
|
||||
let exhausted = false;
|
||||
let truncated = false;
|
||||
|
||||
for (let page = 0; page < MAX_DB_PAGES; page++) {
|
||||
const rows = await svc.listObjectsV2Db({
|
||||
bucket,
|
||||
prefix,
|
||||
startAfter: cursor,
|
||||
maxKeys: DB_WINDOW,
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let stoppedEarly = false;
|
||||
for (const r of rows) {
|
||||
const visible = contents.length + commonPrefixesSet.size;
|
||||
if (visible >= maxKeys) {
|
||||
truncated = true;
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
if (delimiter) {
|
||||
const tail = r.key.slice(prefix.length);
|
||||
const idx = tail.indexOf(delimiter);
|
||||
if (idx >= 0) {
|
||||
const pfx = prefix + tail.slice(0, idx + delimiter.length);
|
||||
if (!commonPrefixesSet.has(pfx)) {
|
||||
if (visible + 1 > maxKeys) {
|
||||
truncated = true;
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
commonPrefixesSet.add(pfx);
|
||||
}
|
||||
cursor = r.key;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
contents.push({
|
||||
Key: r.key,
|
||||
Size: r.size,
|
||||
ETag: `"${r.etag ?? ''}"`,
|
||||
LastModified: r.lastModified.toISOString(),
|
||||
});
|
||||
cursor = r.key;
|
||||
}
|
||||
if (stoppedEarly) {
|
||||
break;
|
||||
}
|
||||
if (rows.length < DB_WINDOW) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exhausted && !truncated && contents.length + commonPrefixesSet.size >= maxKeys) {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const nextContinuation = truncated && cursor ? encodeContinuation(cursor) : undefined;
|
||||
|
||||
const xml = toXml({
|
||||
ListBucketResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Name: bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: maxKeys,
|
||||
KeyCount: contents.length + commonPrefixesSet.size,
|
||||
IsTruncated: truncated,
|
||||
...(nextContinuation ? { NextContinuationToken: nextContinuation } : {}),
|
||||
...(delimiter ? { Delimiter: delimiter } : {}),
|
||||
...(contents.length ? { Contents: contents } : {}),
|
||||
...(commonPrefixesSet.size
|
||||
? { CommonPrefixes: Array.from(commonPrefixesSet).map((p) => ({ Prefix: p })) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { toXml } from '../xml.js';
|
||||
import { sendS3Error } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
const MAX_PART_NUMBER = 10_000;
|
||||
|
||||
// max-parts and part-number-marker are optional integers. Reject negative /
|
||||
// fractional / non-numeric values up front so we don't pass NaN to the
|
||||
// provider or echo it back in the XML response.
|
||||
const querySchema = z.object({
|
||||
uploadId: z.string().min(1),
|
||||
'max-parts': z.coerce.number().int().min(1).max(1000).optional(),
|
||||
'part-number-marker': z.coerce.number().int().min(0).max(MAX_PART_NUMBER).optional(),
|
||||
});
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
parsed.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; '),
|
||||
{ resource: req.path, requestId: req.s3Auth.requestId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { uploadId } = parsed.data;
|
||||
const maxParts = parsed.data['max-parts'];
|
||||
const partNumberMarker = parsed.data['part-number-marker'];
|
||||
|
||||
const result = await StorageService.getInstance()
|
||||
.getProvider()
|
||||
.listParts(bucket, key, uploadId, { maxParts, partNumberMarker });
|
||||
|
||||
const xml = toXml({
|
||||
ListPartsResult: {
|
||||
$: { xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/' },
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MaxParts: maxParts ?? 1000,
|
||||
IsTruncated: result.isTruncated,
|
||||
...(result.nextPartNumberMarker !== undefined && result.nextPartNumberMarker !== null
|
||||
? { NextPartNumberMarker: result.nextPartNumberMarker }
|
||||
: {}),
|
||||
Part: result.parts.map((p) => ({
|
||||
PartNumber: p.partNumber,
|
||||
ETag: `"${p.etag}"`,
|
||||
Size: p.size,
|
||||
LastModified: p.lastModified.toISOString(),
|
||||
})),
|
||||
},
|
||||
});
|
||||
res.status(200).type('application/xml').send(xml);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { parseXml } from '../xml.js';
|
||||
import { sendS3Error, S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
const MAX_CORS_BODY_BYTES = 65536;
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let bodySize = 0;
|
||||
for await (const c of req) {
|
||||
const chunk = c as Buffer;
|
||||
bodySize += chunk.length;
|
||||
if (bodySize > MAX_CORS_BODY_BYTES) {
|
||||
sendS3Error(res, 'EntityTooLarge', 'CORS configuration body exceeds maximum allowed size', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseXml(Buffer.concat(chunks));
|
||||
} catch {
|
||||
sendS3Error(res, 'MalformedXML', 'Request body is not valid XML', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const corsConfig = root.CORSConfiguration as Record<string, unknown> | undefined;
|
||||
if (!corsConfig) {
|
||||
sendS3Error(res, 'MalformedXML', 'Missing CORSConfiguration root element', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let rules: unknown = corsConfig.CORSRule;
|
||||
if (!rules) {
|
||||
rules = [];
|
||||
} else if (!Array.isArray(rules)) {
|
||||
rules = [rules];
|
||||
}
|
||||
const normalizedRules = rules as Array<Record<string, unknown>>;
|
||||
|
||||
if (normalizedRules.length === 0) {
|
||||
sendS3Error(res, 'MalformedXML', 'CORSConfiguration must contain at least one CORSRule', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const VALID_METHODS = new Set(['GET', 'PUT', 'POST', 'DELETE', 'HEAD']);
|
||||
|
||||
for (const [i, rule] of normalizedRules.entries()) {
|
||||
const methods = normalizeArrayField(rule.AllowedMethod);
|
||||
const origins = normalizeArrayField(rule.AllowedOrigin);
|
||||
|
||||
if (methods.length === 0) {
|
||||
sendS3Error(res, 'InvalidArgument', `CORSRule ${i} must have at least one AllowedMethod`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const m of methods) {
|
||||
if (!VALID_METHODS.has(m)) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
`CORSRule ${i} AllowedMethod "${m}" is not a valid HTTP method`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (origins.length === 0) {
|
||||
sendS3Error(res, 'InvalidArgument', `CORSRule ${i} must have at least one AllowedOrigin`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rule.MaxAgeSeconds !== undefined) {
|
||||
const maxAge = Number(rule.MaxAgeSeconds);
|
||||
if (!Number.isFinite(maxAge) || maxAge < 0 || !Number.isInteger(maxAge)) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
`CORSRule ${i} MaxAgeSeconds must be a non-negative integer`
|
||||
);
|
||||
return;
|
||||
}
|
||||
rule.MaxAgeSeconds = maxAge;
|
||||
}
|
||||
rule.AllowedMethod = methods;
|
||||
rule.AllowedOrigin = origins;
|
||||
if (rule.AllowedHeader !== undefined) {
|
||||
rule.AllowedHeader = normalizeArrayField(rule.AllowedHeader);
|
||||
}
|
||||
if (rule.ExposeHeader !== undefined) {
|
||||
rule.ExposeHeader = normalizeArrayField(rule.ExposeHeader);
|
||||
}
|
||||
}
|
||||
|
||||
await svc.putBucketCorsRules(bucket, normalizedRules);
|
||||
res.status(200).send();
|
||||
}
|
||||
|
||||
export function normalizeArrayField(value: unknown): string[] {
|
||||
if (value === undefined || value === null) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(String);
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { parseXml } from '../xml.js';
|
||||
import { sendS3Error, S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket } from '../request.js';
|
||||
|
||||
const VALID_STATUSES = new Set(['Enabled', 'Suspended']);
|
||||
const MAX_VERSIONING_BODY_BYTES = 8192;
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let bodySize = 0;
|
||||
for await (const c of req) {
|
||||
const chunk = c as Buffer;
|
||||
bodySize += chunk.length;
|
||||
if (bodySize > MAX_VERSIONING_BODY_BYTES) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'EntityTooLarge',
|
||||
'Versioning configuration body exceeds maximum allowed size',
|
||||
{
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseXml(Buffer.concat(chunks));
|
||||
} catch {
|
||||
sendS3Error(res, 'MalformedXML', 'Request body is not valid XML', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const config = root.VersioningConfiguration as Record<string, unknown> | undefined;
|
||||
if (!config) {
|
||||
sendS3Error(res, 'MalformedXML', 'Missing VersioningConfiguration root element', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = String(config.Status ?? '');
|
||||
if (!VALID_STATUSES.has(status)) {
|
||||
sendS3Error(res, 'InvalidArgument', `Invalid versioning status "${status}"`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await svc.putBucketVersioningStatus(bucket, status);
|
||||
res.status(200).send();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Response } from 'express';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { parseXml } from '../xml.js';
|
||||
import { sendS3Error, S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
|
||||
const MAX_TAG_COUNT = 10;
|
||||
const MAX_TAG_KEY_LENGTH = 128;
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
const MAX_TAGGING_BODY_BYTES = 65536;
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
throw new S3ProtocolError('NoSuchBucket', `The specified bucket does not exist: ${bucket}`);
|
||||
}
|
||||
|
||||
if (!(await svc.getObjectMetadataRow(bucket, key))) {
|
||||
throw new S3ProtocolError('NoSuchKey', `The specified key does not exist: ${key}`);
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let bodySize = 0;
|
||||
for await (const c of req) {
|
||||
const chunk = c as Buffer;
|
||||
bodySize += chunk.length;
|
||||
if (bodySize > MAX_TAGGING_BODY_BYTES) {
|
||||
sendS3Error(res, 'EntityTooLarge', 'Tagging body exceeds maximum allowed size', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseXml(Buffer.concat(chunks));
|
||||
} catch {
|
||||
sendS3Error(res, 'MalformedXML', 'Request body is not valid XML', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const tagging = root.Tagging as Record<string, unknown> | undefined;
|
||||
if (!tagging) {
|
||||
sendS3Error(res, 'MalformedXML', 'Missing Tagging root element', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tagSet = tagging.TagSet as Record<string, unknown> | undefined;
|
||||
let rawTags = tagSet?.Tag as unknown[] | Record<string, unknown> | undefined;
|
||||
|
||||
const normalizedTags: Array<{ tagKey: string; tagValue: string }> = [];
|
||||
if (rawTags) {
|
||||
if (!Array.isArray(rawTags)) {
|
||||
rawTags = [rawTags];
|
||||
}
|
||||
for (const t of rawTags as Record<string, unknown>[]) {
|
||||
const tagKey = String(t.Key ?? '');
|
||||
const tagValue = String(t.Value ?? '');
|
||||
if (!tagKey || tagKey.length > MAX_TAG_KEY_LENGTH) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
`Tag key must be between 1 and ${MAX_TAG_KEY_LENGTH} characters`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (tagKey.startsWith('aws:')) {
|
||||
sendS3Error(res, 'InvalidArgument', 'Tag keys must not start with "aws:"');
|
||||
return;
|
||||
}
|
||||
if (tagValue.length > MAX_TAG_VALUE_LENGTH) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
`Tag value must be between 0 and ${MAX_TAG_VALUE_LENGTH} characters`
|
||||
);
|
||||
return;
|
||||
}
|
||||
normalizedTags.push({ tagKey, tagValue });
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTags.length > MAX_TAG_COUNT) {
|
||||
sendS3Error(res, 'InvalidArgument', `Tags count must not exceed ${MAX_TAG_COUNT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const t of normalizedTags) {
|
||||
if (seen.has(t.tagKey)) {
|
||||
sendS3Error(res, 'InvalidArgument', `Duplicate tag key: ${t.tagKey}`);
|
||||
return;
|
||||
}
|
||||
seen.add(t.tagKey);
|
||||
}
|
||||
|
||||
await svc.putObjectTags(bucket, key, normalizedTags);
|
||||
res.status(200).send();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Response } from 'express';
|
||||
import { Readable, Transform, TransformCallback } from 'stream';
|
||||
import crypto from 'crypto';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { sendS3Error, S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
import {
|
||||
AwsChunkedPayloadParser,
|
||||
ChunkSignatureV4Parser,
|
||||
} from '@/services/storage/s3-signature.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
|
||||
/**
|
||||
* Transform that counts bytes and errors if it exceeds the cap. Used on the
|
||||
* streaming PutObject / UploadPart path to enforce the per-object ceiling
|
||||
* regardless of whatever Content-Length / x-amz-decoded-content-length the
|
||||
* client declared.
|
||||
*/
|
||||
class ByteLimitStream extends Transform {
|
||||
private received = 0;
|
||||
constructor(private readonly limit: number) {
|
||||
super();
|
||||
}
|
||||
_transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
|
||||
this.received += chunk.length;
|
||||
if (this.received > this.limit) {
|
||||
cb(new S3ProtocolError('EntityTooLarge', `Object exceeds size cap (${this.limit} bytes)`));
|
||||
return;
|
||||
}
|
||||
cb(null, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform that accumulates small writes into larger ones before emitting.
|
||||
* Needed between our aws-chunked parsers and the AWS SDK client we forward
|
||||
* to: the SDK turns every `data` event from the input stream into a single
|
||||
* SigV4 streaming chunk, and real S3 / MinIO reject non-final chunks
|
||||
* smaller than 8 KiB. Our parsers emit whatever slice of a chunk happens
|
||||
* to have arrived on the wire, which after TCP fragmentation is often
|
||||
* <8 KiB. Buffering to 64 KiB guarantees well-sized upstream chunks.
|
||||
*/
|
||||
class MinChunkSizeStream extends Transform {
|
||||
private pending: Buffer[] = [];
|
||||
private pendingLen = 0;
|
||||
constructor(private readonly minBytes: number) {
|
||||
super();
|
||||
}
|
||||
_transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
|
||||
this.pending.push(chunk);
|
||||
this.pendingLen += chunk.length;
|
||||
if (this.pendingLen >= this.minBytes) {
|
||||
this.push(Buffer.concat(this.pending, this.pendingLen));
|
||||
this.pending = [];
|
||||
this.pendingLen = 0;
|
||||
}
|
||||
cb();
|
||||
}
|
||||
_flush(cb: TransformCallback): void {
|
||||
if (this.pendingLen > 0) {
|
||||
this.push(Buffer.concat(this.pending, this.pendingLen));
|
||||
this.pending = [];
|
||||
this.pendingLen = 0;
|
||||
}
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_UPSTREAM_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Parse the decoded payload length from a STREAMING-* request.
|
||||
* `x-amz-decoded-content-length` is the authoritative payload size (bytes
|
||||
* after chunk-framing is stripped). An explicit "0" is a valid length, not
|
||||
* "absent" — a zero-byte streaming upload is legal. Only truly missing /
|
||||
* non-numeric headers are treated as unknown.
|
||||
*/
|
||||
function parseDecodedLength(raw: unknown): number | null {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const svc = StorageService.getInstance();
|
||||
if (!(await svc.bucketExists(bucket))) {
|
||||
sendS3Error(res, 'NoSuchBucket', 'Bucket does not exist', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadHash = req.s3Auth.payloadHash;
|
||||
const isSignedStream = payloadHash === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD';
|
||||
const isSignedStreamTrailer = payloadHash === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER';
|
||||
const isUnsignedStreamTrailer = payloadHash === 'STREAMING-UNSIGNED-PAYLOAD-TRAILER';
|
||||
const isStreaming = isSignedStream || isSignedStreamTrailer || isUnsignedStreamTrailer;
|
||||
const decodedLen = parseDecodedLength(req.headers['x-amz-decoded-content-length']);
|
||||
const plainLen = Number(req.headers['content-length'] ?? 0);
|
||||
// For streaming requests the real payload size is x-amz-decoded-content-length
|
||||
// (0 is valid). For non-streaming we use Content-Length. When neither is
|
||||
// available, we pass undefined to the provider and rely on the running
|
||||
// byte-limit transform to stop the stream if it goes over cap.
|
||||
const contentLength: number | null = isStreaming
|
||||
? decodedLen
|
||||
: Number.isFinite(plainLen)
|
||||
? plainLen
|
||||
: null;
|
||||
|
||||
const cap = appConfig.storage.maxS3UploadSize;
|
||||
if (contentLength !== null && contentLength > cap) {
|
||||
sendS3Error(res, 'EntityTooLarge', `Object too large: ${contentLength} > ${cap}`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (req.headers['content-type'] as string) || 'application/octet-stream';
|
||||
let body: Readable = req;
|
||||
|
||||
if (isSignedStream || isSignedStreamTrailer) {
|
||||
const parser = new ChunkSignatureV4Parser({
|
||||
seedSignature: req.s3Auth.seedSignature,
|
||||
signingKey: req.s3Auth.signingKey,
|
||||
datetime: req.s3Auth.datetime,
|
||||
scope: req.s3Auth.scope,
|
||||
acceptTrailer: isSignedStreamTrailer,
|
||||
});
|
||||
// Pipe chunk-verified output through a byte-limit transform so a client
|
||||
// lying about x-amz-decoded-content-length can't stream past the cap;
|
||||
// coalesce into ≥64 KiB writes so the downstream SDK can sign them as
|
||||
// valid SigV4 streaming chunks for the backing bucket.
|
||||
const limiter = new ByteLimitStream(cap);
|
||||
const coalesce = new MinChunkSizeStream(MIN_UPSTREAM_CHUNK_BYTES);
|
||||
req.pipe(parser).pipe(limiter).pipe(coalesce);
|
||||
body = coalesce;
|
||||
} else if (isUnsignedStreamTrailer) {
|
||||
// Unsigned aws-chunked with trailing integrity checksum — the default
|
||||
// AWS CLI / SDK format once "default integrity protections" rolled out
|
||||
// in 2025. No per-chunk signature to verify; strip framing and rely on
|
||||
// the SigV4 header signature for request authentication.
|
||||
const parser = new AwsChunkedPayloadParser();
|
||||
const limiter = new ByteLimitStream(cap);
|
||||
const coalesce = new MinChunkSizeStream(MIN_UPSTREAM_CHUNK_BYTES);
|
||||
req.pipe(parser).pipe(limiter).pipe(coalesce);
|
||||
body = coalesce;
|
||||
} else if (payloadHash !== 'UNSIGNED-PAYLOAD') {
|
||||
// Pre-hashed body: buffer and verify SHA-256 matches the declared hash.
|
||||
// Enforce a running byte-count cap so a client can't force an unbounded
|
||||
// buffer by lying about Content-Length.
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
const hasher = crypto.createHash('sha256');
|
||||
let tooLarge = false;
|
||||
for await (const c of req) {
|
||||
const b = c as Buffer;
|
||||
received += b.length;
|
||||
if (received > cap) {
|
||||
tooLarge = true;
|
||||
req.unpipe?.();
|
||||
req.destroy?.();
|
||||
break;
|
||||
}
|
||||
hasher.update(b);
|
||||
chunks.push(b);
|
||||
}
|
||||
if (tooLarge) {
|
||||
sendS3Error(res, 'EntityTooLarge', `Object exceeds size cap (${cap} bytes)`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const digest = hasher.digest('hex');
|
||||
if (digest !== req.s3Auth.payloadHash) {
|
||||
sendS3Error(res, 'SignatureDoesNotMatch', 'Body hash mismatch', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
body = Readable.from(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
const result = await svc.getProvider().putObjectStream(bucket, key, body, {
|
||||
contentType,
|
||||
contentLength: contentLength ?? undefined,
|
||||
});
|
||||
|
||||
await svc.upsertS3Object({
|
||||
bucket,
|
||||
key,
|
||||
size: result.size || contentLength || 0,
|
||||
etag: result.etag,
|
||||
contentType,
|
||||
s3AccessKeyId: req.s3Auth.accessKeyId,
|
||||
});
|
||||
|
||||
res.status(200).set('ETag', `"${result.etag}"`).send();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Response } from 'express';
|
||||
import { Readable, Transform, TransformCallback } from 'stream';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import {
|
||||
AwsChunkedPayloadParser,
|
||||
ChunkSignatureV4Parser,
|
||||
} from '@/services/storage/s3-signature.js';
|
||||
import { sendS3Error, S3ProtocolError } from '../errors.js';
|
||||
import { S3GatewayRequest, getS3Bucket, getS3Key } from '../request.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
|
||||
const MAX_PART_NUMBER = 10_000;
|
||||
const AWS_S3_PER_PART_HARD_CAP = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
class ByteLimitStream extends Transform {
|
||||
private received = 0;
|
||||
constructor(private readonly limit: number) {
|
||||
super();
|
||||
}
|
||||
_transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
|
||||
this.received += chunk.length;
|
||||
if (this.received > this.limit) {
|
||||
cb(new S3ProtocolError('EntityTooLarge', `Part exceeds size cap (${this.limit} bytes)`));
|
||||
return;
|
||||
}
|
||||
cb(null, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce small writes from our aws-chunked parser into ≥64 KiB buffers so
|
||||
* the AWS SDK forwards valid SigV4 streaming chunks to the backing bucket
|
||||
* (S3 rejects non-final chunks <8 KiB).
|
||||
*/
|
||||
class MinChunkSizeStream extends Transform {
|
||||
private pending: Buffer[] = [];
|
||||
private pendingLen = 0;
|
||||
constructor(private readonly minBytes: number) {
|
||||
super();
|
||||
}
|
||||
_transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
|
||||
this.pending.push(chunk);
|
||||
this.pendingLen += chunk.length;
|
||||
if (this.pendingLen >= this.minBytes) {
|
||||
this.push(Buffer.concat(this.pending, this.pendingLen));
|
||||
this.pending = [];
|
||||
this.pendingLen = 0;
|
||||
}
|
||||
cb();
|
||||
}
|
||||
_flush(cb: TransformCallback): void {
|
||||
if (this.pendingLen > 0) {
|
||||
this.push(Buffer.concat(this.pending, this.pendingLen));
|
||||
this.pending = [];
|
||||
this.pendingLen = 0;
|
||||
}
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_UPSTREAM_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
function parseDecodedLength(raw: unknown): number | null {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
export async function handle(req: S3GatewayRequest, res: Response): Promise<void> {
|
||||
const bucket = getS3Bucket(req);
|
||||
const key = getS3Key(req);
|
||||
const partLimit = Math.min(appConfig.storage.maxS3UploadSize, AWS_S3_PER_PART_HARD_CAP);
|
||||
|
||||
const partNumberRaw = req.query.partNumber;
|
||||
const uploadIdRaw = req.query.uploadId;
|
||||
const partNumberStr = typeof partNumberRaw === 'string' ? partNumberRaw : '';
|
||||
const uploadId = typeof uploadIdRaw === 'string' ? uploadIdRaw : '';
|
||||
const partNumber = /^\d+$/.test(partNumberStr) ? Number(partNumberStr) : NaN;
|
||||
if (!uploadId) {
|
||||
sendS3Error(res, 'InvalidRequest', 'Missing uploadId', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_PART_NUMBER) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'InvalidArgument',
|
||||
`partNumber must be an integer in [1, ${MAX_PART_NUMBER}]`,
|
||||
{ resource: req.path, requestId: req.s3Auth.requestId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadHash = req.s3Auth.payloadHash;
|
||||
const isSignedStream = payloadHash === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD';
|
||||
const isSignedStreamTrailer = payloadHash === 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER';
|
||||
const isUnsignedStreamTrailer = payloadHash === 'STREAMING-UNSIGNED-PAYLOAD-TRAILER';
|
||||
const isStreaming = isSignedStream || isSignedStreamTrailer || isUnsignedStreamTrailer;
|
||||
const decodedLen = parseDecodedLength(req.headers['x-amz-decoded-content-length']);
|
||||
const plainLen = Number(req.headers['content-length'] ?? 0);
|
||||
// Streaming parts: x-amz-decoded-content-length is authoritative (0 is valid).
|
||||
// Non-streaming: Content-Length is the payload size.
|
||||
const contentLength: number | null = isStreaming
|
||||
? decodedLen
|
||||
: Number.isFinite(plainLen) && plainLen >= 0
|
||||
? plainLen
|
||||
: null;
|
||||
|
||||
if (contentLength === null) {
|
||||
sendS3Error(res, 'InvalidArgument', 'Missing or invalid Content-Length', {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (contentLength > partLimit) {
|
||||
sendS3Error(res, 'EntityTooLarge', `Part too large: ${contentLength}`, {
|
||||
resource: req.path,
|
||||
requestId: req.s3Auth.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let body: Readable = req;
|
||||
if (isSignedStream || isSignedStreamTrailer) {
|
||||
const parser = new ChunkSignatureV4Parser({
|
||||
seedSignature: req.s3Auth.seedSignature,
|
||||
signingKey: req.s3Auth.signingKey,
|
||||
datetime: req.s3Auth.datetime,
|
||||
scope: req.s3Auth.scope,
|
||||
acceptTrailer: isSignedStreamTrailer,
|
||||
});
|
||||
const limiter = new ByteLimitStream(partLimit);
|
||||
const coalesce = new MinChunkSizeStream(MIN_UPSTREAM_CHUNK_BYTES);
|
||||
req.pipe(parser).pipe(limiter).pipe(coalesce);
|
||||
body = coalesce;
|
||||
} else if (isUnsignedStreamTrailer) {
|
||||
const parser = new AwsChunkedPayloadParser();
|
||||
const limiter = new ByteLimitStream(partLimit);
|
||||
const coalesce = new MinChunkSizeStream(MIN_UPSTREAM_CHUNK_BYTES);
|
||||
req.pipe(parser).pipe(limiter).pipe(coalesce);
|
||||
body = coalesce;
|
||||
}
|
||||
|
||||
const { etag } = await StorageService.getInstance()
|
||||
.getProvider()
|
||||
.uploadPart(bucket, key, uploadId, partNumber, body, contentLength);
|
||||
|
||||
res.status(200).set('ETag', `"${etag}"`).send();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
export type S3Op =
|
||||
| 'ListBuckets'
|
||||
| 'CreateBucket'
|
||||
| 'DeleteBucket'
|
||||
| 'HeadBucket'
|
||||
| 'ListObjectsV2'
|
||||
| 'PutObject'
|
||||
| 'GetObject'
|
||||
| 'HeadObject'
|
||||
| 'DeleteObject'
|
||||
| 'DeleteObjects'
|
||||
| 'CopyObject'
|
||||
| 'CreateMultipartUpload'
|
||||
| 'UploadPart'
|
||||
| 'CompleteMultipartUpload'
|
||||
| 'AbortMultipartUpload'
|
||||
| 'ListParts'
|
||||
| 'GetBucketLocation'
|
||||
| 'GetBucketVersioning'
|
||||
| 'PutBucketVersioning'
|
||||
| 'GetBucketCors'
|
||||
| 'PutBucketCors'
|
||||
| 'DeleteBucketCors'
|
||||
| 'GetObjectTagging'
|
||||
| 'PutObjectTagging'
|
||||
| 'DeleteObjectTagging';
|
||||
|
||||
interface Req {
|
||||
method: string;
|
||||
path: string;
|
||||
query: Record<string, string | string[] | undefined>;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
function header(h: Req['headers'], name: string): string | undefined {
|
||||
const direct = h[name] ?? h[name.toLowerCase()];
|
||||
if (Array.isArray(direct)) {
|
||||
return direct[0];
|
||||
}
|
||||
return direct;
|
||||
}
|
||||
|
||||
function hasKey(path: string): boolean {
|
||||
const trimmed = path.replace(/^\/+/, '');
|
||||
return trimmed.includes('/');
|
||||
}
|
||||
|
||||
function bucketOnly(path: string): boolean {
|
||||
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
return trimmed.length > 0 && !trimmed.includes('/');
|
||||
}
|
||||
|
||||
export function dispatchOp(req: Req): S3Op | null {
|
||||
const { method, path, query } = req;
|
||||
const m = method.toUpperCase();
|
||||
const q = new Set(Object.keys(query));
|
||||
|
||||
// Root — ListBuckets only
|
||||
if (path === '/' || path === '') {
|
||||
return m === 'GET' ? 'ListBuckets' : null;
|
||||
}
|
||||
|
||||
// Bucket-level (no object key)
|
||||
if (bucketOnly(path)) {
|
||||
if (m === 'GET') {
|
||||
if (q.has('location')) {
|
||||
return 'GetBucketLocation';
|
||||
}
|
||||
if (q.has('versioning')) {
|
||||
return 'GetBucketVersioning';
|
||||
}
|
||||
if (q.has('cors')) {
|
||||
return 'GetBucketCors';
|
||||
}
|
||||
return 'ListObjectsV2';
|
||||
}
|
||||
if (m === 'HEAD') {
|
||||
return 'HeadBucket';
|
||||
}
|
||||
if (m === 'PUT') {
|
||||
if (q.has('versioning')) {
|
||||
return 'PutBucketVersioning';
|
||||
}
|
||||
if (q.has('cors')) {
|
||||
return 'PutBucketCors';
|
||||
}
|
||||
return 'CreateBucket';
|
||||
}
|
||||
if (m === 'DELETE') {
|
||||
if (q.has('cors')) {
|
||||
return 'DeleteBucketCors';
|
||||
}
|
||||
return 'DeleteBucket';
|
||||
}
|
||||
if (m === 'POST' && q.has('delete')) {
|
||||
return 'DeleteObjects';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Object-level
|
||||
if (hasKey(path)) {
|
||||
if (m === 'PUT') {
|
||||
if (q.has('uploadId') && q.has('partNumber')) {
|
||||
return 'UploadPart';
|
||||
}
|
||||
if (header(req.headers, 'x-amz-copy-source')) {
|
||||
return 'CopyObject';
|
||||
}
|
||||
if (q.has('tagging')) {
|
||||
return 'PutObjectTagging';
|
||||
}
|
||||
return 'PutObject';
|
||||
}
|
||||
if (m === 'POST') {
|
||||
if (q.has('uploads')) {
|
||||
return 'CreateMultipartUpload';
|
||||
}
|
||||
if (q.has('uploadId')) {
|
||||
return 'CompleteMultipartUpload';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (m === 'GET') {
|
||||
if (q.has('uploadId')) {
|
||||
return 'ListParts';
|
||||
}
|
||||
if (q.has('tagging')) {
|
||||
return 'GetObjectTagging';
|
||||
}
|
||||
return 'GetObject';
|
||||
}
|
||||
if (m === 'HEAD') {
|
||||
return 'HeadObject';
|
||||
}
|
||||
if (m === 'DELETE') {
|
||||
if (q.has('uploadId')) {
|
||||
return 'AbortMultipartUpload';
|
||||
}
|
||||
if (q.has('tagging')) {
|
||||
return 'DeleteObjectTagging';
|
||||
}
|
||||
return 'DeleteObject';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseBucketAndKey(path: string): { bucket: string | null; key: string | null } {
|
||||
const trimmed = path.replace(/^\/+/, '');
|
||||
if (!trimmed) {
|
||||
return { bucket: null, key: null };
|
||||
}
|
||||
const slash = trimmed.indexOf('/');
|
||||
if (slash === -1) {
|
||||
return { bucket: trimmed.replace(/\/+$/, ''), key: null };
|
||||
}
|
||||
return { bucket: trimmed.slice(0, slash), key: trimmed.slice(slash + 1) };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Response } from 'express';
|
||||
import { toXml } from './xml.js';
|
||||
|
||||
export type S3ErrorCode =
|
||||
| 'SignatureDoesNotMatch'
|
||||
| 'InvalidAccessKeyId'
|
||||
| 'RequestTimeTooSkewed'
|
||||
| 'AuthorizationHeaderMalformed'
|
||||
| 'NoSuchBucket'
|
||||
| 'NoSuchKey'
|
||||
| 'NoSuchCORSConfiguration'
|
||||
| 'BucketAlreadyOwnedByYou'
|
||||
| 'BucketNotEmpty'
|
||||
| 'InvalidBucketName'
|
||||
| 'EntityTooLarge'
|
||||
| 'EntityTooSmall'
|
||||
| 'NotImplemented'
|
||||
| 'InternalError'
|
||||
| 'InvalidRequest'
|
||||
| 'InvalidArgument'
|
||||
| 'InvalidPart'
|
||||
| 'MalformedXML'
|
||||
| 'MethodNotAllowed';
|
||||
|
||||
const statusMap: Record<S3ErrorCode, number> = {
|
||||
SignatureDoesNotMatch: 403,
|
||||
InvalidAccessKeyId: 403,
|
||||
RequestTimeTooSkewed: 403,
|
||||
AuthorizationHeaderMalformed: 400,
|
||||
NoSuchBucket: 404,
|
||||
NoSuchKey: 404,
|
||||
NoSuchCORSConfiguration: 404,
|
||||
BucketAlreadyOwnedByYou: 409,
|
||||
BucketNotEmpty: 409,
|
||||
InvalidBucketName: 400,
|
||||
EntityTooLarge: 400,
|
||||
EntityTooSmall: 400,
|
||||
NotImplemented: 501,
|
||||
InternalError: 500,
|
||||
InvalidRequest: 400,
|
||||
InvalidArgument: 400,
|
||||
InvalidPart: 400,
|
||||
MalformedXML: 400,
|
||||
MethodNotAllowed: 405,
|
||||
};
|
||||
|
||||
export function sendS3Error(
|
||||
res: Response,
|
||||
code: S3ErrorCode,
|
||||
message: string,
|
||||
opts?: { resource?: string; requestId?: string }
|
||||
): void {
|
||||
const status = statusMap[code];
|
||||
const xml = toXml({
|
||||
Error: {
|
||||
Code: code,
|
||||
Message: message,
|
||||
Resource: opts?.resource ?? '',
|
||||
RequestId: opts?.requestId ?? '',
|
||||
},
|
||||
});
|
||||
res.status(status).type('application/xml').send(xml);
|
||||
}
|
||||
|
||||
export class S3ProtocolError extends Error {
|
||||
constructor(
|
||||
public readonly code: S3ErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { s3Sigv4Middleware, S3AuthenticatedRequest } from '@/api/middlewares/s3-sigv4.js';
|
||||
import { dispatchOp, parseBucketAndKey, S3Op } from './dispatch.js';
|
||||
import { S3GatewayRequest } from './request.js';
|
||||
import { sendS3Error, S3ProtocolError } from './errors.js';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
import * as listBuckets from './commands/list-buckets.js';
|
||||
import * as headBucket from './commands/head-bucket.js';
|
||||
import * as createBucket from './commands/create-bucket.js';
|
||||
import * as deleteBucket from './commands/delete-bucket.js';
|
||||
import * as listObjectsV2 from './commands/list-objects-v2.js';
|
||||
import * as headObject from './commands/head-object.js';
|
||||
import * as getObject from './commands/get-object.js';
|
||||
import * as putObject from './commands/put-object.js';
|
||||
import * as deleteObject from './commands/delete-object.js';
|
||||
import * as deleteObjects from './commands/delete-objects.js';
|
||||
import * as copyObject from './commands/copy-object.js';
|
||||
import * as createMultipartUpload from './commands/create-multipart-upload.js';
|
||||
import * as uploadPart from './commands/upload-part.js';
|
||||
import * as completeMultipartUpload from './commands/complete-multipart-upload.js';
|
||||
import * as abortMultipartUpload from './commands/abort-multipart-upload.js';
|
||||
import * as listParts from './commands/list-parts.js';
|
||||
import * as getBucketLocation from './commands/get-bucket-location.js';
|
||||
import * as getBucketVersioning from './commands/get-bucket-versioning.js';
|
||||
import * as getBucketCors from './commands/get-bucket-cors.js';
|
||||
import * as putBucketCors from './commands/put-bucket-cors.js';
|
||||
import * as deleteBucketCors from './commands/delete-bucket-cors.js';
|
||||
import * as getObjectTagging from './commands/get-object-tagging.js';
|
||||
import * as putObjectTagging from './commands/put-object-tagging.js';
|
||||
import * as deleteObjectTagging from './commands/delete-object-tagging.js';
|
||||
import * as putBucketVersioning from './commands/put-bucket-versioning.js';
|
||||
|
||||
type Handler = (req: S3GatewayRequest, res: Response) => Promise<void>;
|
||||
|
||||
const handlers: Record<S3Op, Handler> = {
|
||||
ListBuckets: listBuckets.handle,
|
||||
HeadBucket: headBucket.handle,
|
||||
CreateBucket: createBucket.handle,
|
||||
DeleteBucket: deleteBucket.handle,
|
||||
ListObjectsV2: listObjectsV2.handle,
|
||||
HeadObject: headObject.handle,
|
||||
GetObject: getObject.handle,
|
||||
PutObject: putObject.handle,
|
||||
DeleteObject: deleteObject.handle,
|
||||
DeleteObjects: deleteObjects.handle,
|
||||
CopyObject: copyObject.handle,
|
||||
CreateMultipartUpload: createMultipartUpload.handle,
|
||||
UploadPart: uploadPart.handle,
|
||||
CompleteMultipartUpload: completeMultipartUpload.handle,
|
||||
AbortMultipartUpload: abortMultipartUpload.handle,
|
||||
ListParts: listParts.handle,
|
||||
GetBucketLocation: getBucketLocation.handle,
|
||||
GetBucketVersioning: getBucketVersioning.handle,
|
||||
PutBucketVersioning: putBucketVersioning.handle,
|
||||
GetBucketCors: getBucketCors.handle,
|
||||
PutBucketCors: putBucketCors.handle,
|
||||
DeleteBucketCors: deleteBucketCors.handle,
|
||||
GetObjectTagging: getObjectTagging.handle,
|
||||
PutObjectTagging: putObjectTagging.handle,
|
||||
DeleteObjectTagging: deleteObjectTagging.handle,
|
||||
};
|
||||
|
||||
export const s3GatewayRouter: Router = Router();
|
||||
|
||||
// 1) Refuse at mount if backend isn't S3-compatible.
|
||||
s3GatewayRouter.use((req: Request, res: Response, next) => {
|
||||
if (!StorageService.getInstance().isS3Provider()) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'NotImplemented',
|
||||
'S3 protocol requires an S3 storage backend. Set AWS_S3_BUCKET.',
|
||||
{ resource: req.path }
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 2) SigV4 authentication. Express 4 doesn't auto-forward rejected promises
|
||||
// from async middleware — chain .catch(next) so DB/service errors inside
|
||||
// the middleware hit the error handler instead of hanging the request.
|
||||
s3GatewayRouter.use((req, res, next) => {
|
||||
s3Sigv4Middleware(req, res, next).catch(next);
|
||||
});
|
||||
|
||||
// 3) Early Content-Length check for body-consuming operations.
|
||||
// For aws-chunked streaming uploads the wire Content-Length includes
|
||||
// chunk framing overhead; use x-amz-decoded-content-length instead.
|
||||
s3GatewayRouter.use((req: Request, res: Response, next) => {
|
||||
const rawDecoded = req.headers['x-amz-decoded-content-length'];
|
||||
const isStreaming = typeof rawDecoded === 'string' && /^\d+$/.test(rawDecoded);
|
||||
const rawCl = req.headers['content-length'];
|
||||
const contentLength = isStreaming
|
||||
? Number(rawDecoded)
|
||||
: typeof rawCl === 'string' && /^\d+$/.test(rawCl)
|
||||
? Number(rawCl)
|
||||
: null;
|
||||
if (contentLength === null || contentLength <= appConfig.storage.maxS3UploadSize) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const m = req.method.toUpperCase();
|
||||
const p = req.path;
|
||||
const q = new Set(Object.keys(req.query));
|
||||
const hasKey = p.replace(/^\/+/, '').includes('/');
|
||||
const isLargeBodyOp =
|
||||
m === 'PUT' && hasKey && !q.has('tagging') && !req.headers['x-amz-copy-source'];
|
||||
if (isLargeBodyOp) {
|
||||
sendS3Error(
|
||||
res,
|
||||
'EntityTooLarge',
|
||||
`Object exceeds max upload size (${appConfig.storage.maxS3UploadSize} bytes)`,
|
||||
{
|
||||
resource: req.path,
|
||||
requestId: (req as S3AuthenticatedRequest).s3Auth?.requestId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 4) Dispatch to the operation handler.
|
||||
s3GatewayRouter.use(async (req: Request, res: Response) => {
|
||||
const query: Record<string, string | string[] | undefined> = {};
|
||||
for (const [k, v] of Object.entries(req.query)) {
|
||||
if (typeof v === 'string' || Array.isArray(v) || v === undefined) {
|
||||
query[k] = v as string | string[] | undefined;
|
||||
}
|
||||
}
|
||||
const op: S3Op | null = dispatchOp({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query,
|
||||
headers: req.headers,
|
||||
});
|
||||
if (!op) {
|
||||
sendS3Error(res, 'MethodNotAllowed', `Method ${req.method} not allowed`, {
|
||||
resource: req.path,
|
||||
requestId: (req as S3AuthenticatedRequest).s3Auth?.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { bucket, key } = parseBucketAndKey(req.path);
|
||||
const authed = req as S3GatewayRequest;
|
||||
authed.s3Op = op;
|
||||
authed.s3Bucket = bucket;
|
||||
authed.s3Key = key;
|
||||
logger.debug('S3 gateway dispatch', { op, bucket, key });
|
||||
try {
|
||||
await handlers[op](authed, res);
|
||||
} catch (err) {
|
||||
if (res.headersSent) {
|
||||
logger.error('S3 gateway handler error after headers sent', { op, err });
|
||||
return;
|
||||
}
|
||||
// Typed protocol error — use its S3 code/status directly.
|
||||
if (err instanceof S3ProtocolError) {
|
||||
sendS3Error(res, err.code, err.message, {
|
||||
resource: req.path,
|
||||
requestId: authed.s3Auth?.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Chunk signature failures bubble out of the streaming parser as plain
|
||||
// Error with 'SignatureDoesNotMatch' in the message — translate to the
|
||||
// S3 auth error rather than a generic 500.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('SignatureDoesNotMatch')) {
|
||||
logger.warn('S3 gateway chunk signature failure', { op, err });
|
||||
sendS3Error(res, 'SignatureDoesNotMatch', msg, {
|
||||
resource: req.path,
|
||||
requestId: authed.s3Auth?.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.error('S3 gateway handler error', { op, err });
|
||||
sendS3Error(res, 'InternalError', msg, {
|
||||
resource: req.path,
|
||||
requestId: authed.s3Auth?.requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { S3Op } from './dispatch.js';
|
||||
import { S3ProtocolError } from './errors.js';
|
||||
import type { S3AuthenticatedRequest } from '@/api/middlewares/s3-sigv4.js';
|
||||
|
||||
/**
|
||||
* A SigV4-authenticated request after the gateway dispatch middleware has
|
||||
* resolved the operation and parsed bucket/key from the path. `s3Bucket` and
|
||||
* `s3Key` are null for operations that don't address them (ListBuckets has
|
||||
* neither; bucket-level ops have no key), so command handlers read them through
|
||||
* the accessors below rather than asserting non-null at each call site.
|
||||
*/
|
||||
export interface S3GatewayRequest extends S3AuthenticatedRequest {
|
||||
s3Op: S3Op;
|
||||
s3Bucket: string | null;
|
||||
s3Key: string | null;
|
||||
}
|
||||
|
||||
/** The target bucket for a bucket/object operation. */
|
||||
export function getS3Bucket(req: S3GatewayRequest): string {
|
||||
if (req.s3Bucket === null) {
|
||||
throw new S3ProtocolError('InvalidRequest', 'Missing bucket in request path');
|
||||
}
|
||||
return req.s3Bucket;
|
||||
}
|
||||
|
||||
/** The target object key for an object operation. */
|
||||
export function getS3Key(req: S3GatewayRequest): string {
|
||||
if (req.s3Key === null) {
|
||||
throw new S3ProtocolError('InvalidRequest', 'Missing object key in request path');
|
||||
}
|
||||
return req.s3Key;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Builder, Parser } from 'xml2js';
|
||||
|
||||
const builder = new Builder({
|
||||
xmldec: { version: '1.0', encoding: 'UTF-8' },
|
||||
renderOpts: { pretty: false },
|
||||
headless: false,
|
||||
});
|
||||
|
||||
export function toXml(root: Record<string, unknown>): string {
|
||||
return builder.buildObject(root);
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
export function parseXml(input: string | Buffer): Promise<unknown> {
|
||||
return parser.parseStringPromise(input.toString('utf8'));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { AuthRequest, verifyAdmin } from '@/api/middlewares/auth.js';
|
||||
import { ScheduleService } from '@/services/schedules/schedule.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createScheduleRequestSchema,
|
||||
updateScheduleRequestSchema,
|
||||
getSchedulesConfigResponseSchema,
|
||||
updateSchedulesConfigRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const scheduleService = ScheduleService.getInstance();
|
||||
|
||||
// All schedule routes require authentication
|
||||
router.use(verifyAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/schedules
|
||||
* List all schedules
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schedules = await scheduleService.listSchedules();
|
||||
successResponse(res, schedules);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/config
|
||||
* Get schedules config (retention days)
|
||||
*/
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = getSchedulesConfigResponseSchema.parse({
|
||||
retentionDays: await scheduleService.getRetentionDays(),
|
||||
});
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/schedules/config
|
||||
* Update schedules config (retention days)
|
||||
*/
|
||||
router.patch('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = updateSchedulesConfigRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { retentionDays } = validation.data;
|
||||
await scheduleService.updateRetentionDays(retentionDays);
|
||||
successResponse(res, { message: 'Schedules config updated successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/:id
|
||||
* Get a single schedule by its ID
|
||||
*/
|
||||
router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const schedule = await scheduleService.getScheduleById(id);
|
||||
if (!schedule) {
|
||||
throw new AppError('Schedule not found.', 404, ERROR_CODES.SCHEDULE_NOT_FOUND);
|
||||
}
|
||||
successResponse(res, schedule);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schedules/:id/logs
|
||||
* Get execution logs for a schedule
|
||||
*/
|
||||
router.get('/:id/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const limit = Math.min(Math.max(1, parseInt(req.query.limit as string) || 50), 100);
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const result = await scheduleService.getExecutionLogs(id, limit, offset);
|
||||
successResponse(res, {
|
||||
logs: result.logs,
|
||||
totalCount: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/schedules/:id
|
||||
* Delete a schedule by its ID
|
||||
*/
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await scheduleService.deleteSchedule(id);
|
||||
successResponse(res, { message: 'Schedule deleted successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/schedules
|
||||
* Create a new schedule
|
||||
*/
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createScheduleRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await scheduleService.createSchedule(validation.data);
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
id: result.id,
|
||||
cronJobId: result.cron_job_id,
|
||||
message: 'Schedule created successfully',
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/schedules/:id
|
||||
* Update a schedule (partial update, including toggle)
|
||||
*/
|
||||
router.patch('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const validation = updateScheduleRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await scheduleService.updateSchedule(id, validation.data);
|
||||
successResponse(res, {
|
||||
id: result.id,
|
||||
cronJobId: result.cron_job_id,
|
||||
message: 'Schedule updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as schedulesRouter };
|
||||
@@ -0,0 +1,348 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { SecretService } from '@/services/secrets/secret.service.js';
|
||||
import { FunctionService } from '@/services/functions/function.service.js';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
rotateAnonKeyRequestSchema,
|
||||
rotateApiKeyRequestSchema,
|
||||
type RotateAnonKeyResponse,
|
||||
type RotateApiKeyResponse,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
|
||||
const router = Router();
|
||||
const secretService = SecretService.getInstance();
|
||||
const auditService = AuditService.getInstance();
|
||||
const functionService = FunctionService.getInstance();
|
||||
|
||||
// Trigger function redeployment with updated secrets (non-blocking, debounced)
|
||||
const triggerSecretsRedeployment = () => {
|
||||
if (functionService.isSubhostingConfigured()) {
|
||||
functionService.redeploy();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all secrets (metadata only, no values)
|
||||
* GET /api/secrets
|
||||
*/
|
||||
router.get('/', verifyAdmin, async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const secrets = await secretService.listSecrets();
|
||||
successResponse(res, { secrets });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a specific secret value by key
|
||||
* GET /api/secrets/:key
|
||||
*/
|
||||
router.get('/:key', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const value = await secretService.getSecretByKey(key);
|
||||
|
||||
if (value === null) {
|
||||
throw new AppError(`Secret not found: ${key}`, 404, ERROR_CODES.SECRET_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'GET_SECRET',
|
||||
module: 'SECRETS',
|
||||
details: { key },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, { key, value });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new secret
|
||||
* POST /api/secrets
|
||||
*/
|
||||
router.post('/', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { key, value, isReserved, expiresAt } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!key || !value) {
|
||||
throw new AppError('Both key and value are required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Validate key format (uppercase alphanumeric with underscores only)
|
||||
if (!/^[A-Z0-9_]+$/.test(key)) {
|
||||
throw new AppError(
|
||||
'Invalid key format. Use uppercase letters, numbers, and underscores only (e.g., STRIPE_API_KEY)',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
// Check if secret already exists
|
||||
const secrets = await secretService.listSecrets();
|
||||
const secret = secrets.find((s) => s.key === key);
|
||||
|
||||
let result: { id: string };
|
||||
|
||||
if (secret && secret?.isActive) {
|
||||
throw new AppError(`Secret already exists: ${key}`, 409, ERROR_CODES.SECRET_ALREADY_EXISTS);
|
||||
} else if (secret && !secret?.isActive && secret?.id) {
|
||||
const success = await secretService.updateSecret(secret?.id, {
|
||||
value,
|
||||
isActive: true,
|
||||
isReserved: isReserved || false,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
||||
});
|
||||
if (!success) {
|
||||
throw new AppError(`Failed to create secret: ${key}`, 500, ERROR_CODES.INTERNAL_ERROR);
|
||||
}
|
||||
result = { id: secret.id };
|
||||
} else {
|
||||
result = await secretService.createSecret({
|
||||
key,
|
||||
value,
|
||||
isReserved: isReserved || false,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_SECRET',
|
||||
module: 'SECRETS',
|
||||
details: { key, id: result.id },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Trigger redeployment with new secret
|
||||
triggerSecretsRedeployment();
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
success: true,
|
||||
message: `Secret ${key} has been created successfully`,
|
||||
id: result.id,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update an existing secret
|
||||
* PUT /api/secrets/:key
|
||||
*/
|
||||
router.put('/:key', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { value, isActive, isReserved, expiresAt } = req.body;
|
||||
|
||||
// Get existing secret
|
||||
const secrets = await secretService.listSecrets();
|
||||
const secret = secrets.find((s) => s.key === key);
|
||||
|
||||
if (!secret) {
|
||||
throw new AppError(`Secret not found: ${key}`, 404, ERROR_CODES.SECRET_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Reserved secrets (ANON_KEY, API_KEY, JWT_SECRET, ...) are managed by
|
||||
// the platform — seeded at startup, changed only through dedicated flows
|
||||
// like the rotate endpoints. Same rule as DELETE below.
|
||||
if (secret.isReserved) {
|
||||
throw new AppError(`Cannot update reserved secret: ${key}`, 403, ERROR_CODES.FORBIDDEN);
|
||||
}
|
||||
|
||||
const success = await secretService.updateSecret(secret.id, {
|
||||
value,
|
||||
isActive,
|
||||
isReserved,
|
||||
expiresAt: expiresAt !== undefined ? (expiresAt ? new Date(expiresAt) : null) : undefined,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new AppError(`Failed to update secret: ${key}`, 500, ERROR_CODES.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_SECRET',
|
||||
module: 'SECRETS',
|
||||
details: { key, updates: { hasNewValue: !!value, isActive, isReserved, expiresAt } },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Trigger redeployment if value changed (check undefined, not truthy, to allow empty strings)
|
||||
if (value !== undefined) {
|
||||
triggerSecretsRedeployment();
|
||||
}
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `Secret ${key} has been updated successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Rotate API key with grace period
|
||||
* POST /api/secrets/api-key/rotate
|
||||
*/
|
||||
router.post(
|
||||
'/api-key/rotate',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parseResult = rotateApiKeyRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new AppError(
|
||||
`Invalid request: ${parseResult.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { gracePeriodHours } = parseResult.data;
|
||||
const result = await secretService.rotateApiKey(gracePeriodHours);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'ROTATE_API_KEY',
|
||||
module: 'SECRETS',
|
||||
details: {
|
||||
oldKeyExpiresAt: result.oldKeyExpiresAt.toISOString(),
|
||||
gracePeriodHours: gracePeriodHours ?? 24,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const response: RotateApiKeyResponse = {
|
||||
success: true,
|
||||
message: 'API key rotated successfully. Old key will remain valid during grace period.',
|
||||
apiKey: result.newApiKey,
|
||||
oldKeyExpiresAt: result.oldKeyExpiresAt.toISOString(),
|
||||
};
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Rotate anon key with grace period
|
||||
* POST /api/secrets/anon-key/rotate
|
||||
*/
|
||||
router.post(
|
||||
'/anon-key/rotate',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parseResult = rotateAnonKeyRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new AppError(
|
||||
`Invalid request: ${parseResult.error.errors.map((e) => e.message).join(', ')}`,
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const { gracePeriodHours } = parseResult.data;
|
||||
const result = await secretService.rotateAnonKey(gracePeriodHours);
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'ROTATE_ANON_KEY',
|
||||
module: 'SECRETS',
|
||||
details: {
|
||||
oldKeyExpiresAt: result.oldKeyExpiresAt.toISOString(),
|
||||
gracePeriodHours: gracePeriodHours ?? 168,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// ANON_KEY is injected into edge function environments, so redeploy
|
||||
// with the new value
|
||||
triggerSecretsRedeployment();
|
||||
|
||||
const response: RotateAnonKeyResponse = {
|
||||
success: true,
|
||||
message: 'Anon key rotated successfully. Old key will remain valid during grace period.',
|
||||
anonKey: result.newAnonKey,
|
||||
oldKeyExpiresAt: result.oldKeyExpiresAt.toISOString(),
|
||||
};
|
||||
successResponse(res, response);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a secret (mark as inactive)
|
||||
* DELETE /api/secrets/:key
|
||||
*/
|
||||
router.delete('/:key', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
|
||||
// Get existing secret
|
||||
const secrets = await secretService.listSecrets();
|
||||
const secret = secrets.find((s) => s.key === key);
|
||||
|
||||
if (!secret) {
|
||||
throw new AppError(`Secret not found: ${key}`, 404, ERROR_CODES.SECRET_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check if secret is reserved
|
||||
if (secret.isReserved) {
|
||||
throw new AppError(`Cannot delete reserved secret: ${key}`, 403, ERROR_CODES.FORBIDDEN);
|
||||
}
|
||||
|
||||
// Mark as inactive instead of hard delete
|
||||
const success = await secretService.updateSecret(secret.id, { isActive: false });
|
||||
|
||||
if (!success) {
|
||||
throw new AppError(`Failed to delete secret: ${key}`, 500, ERROR_CODES.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
// Log audit
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_SECRET',
|
||||
module: 'SECRETS',
|
||||
details: { key },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// Trigger redeployment without deleted secret
|
||||
triggerSecretsRedeployment();
|
||||
|
||||
successResponse(res, {
|
||||
success: true,
|
||||
message: `Secret ${key} has been deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,895 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest, verifyUser } from '@/api/middlewares/auth.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { StorageService } from '@/services/storage/storage.service.js';
|
||||
import { StorageConfigService } from '@/services/storage/storage-config.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { dynamicUploadSingle, handleUploadError } from '@/api/middlewares/upload.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
createBucketRequestSchema,
|
||||
deleteObjectsRequestSchema,
|
||||
updateBucketRequestSchema,
|
||||
updateStorageConfigRequestSchema,
|
||||
createS3AccessKeyRequestSchema,
|
||||
} from '@insforge/shared-schemas';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import { AuditService } from '@/services/logs/audit.service.js';
|
||||
import { S3AccessKeyService } from '@/services/storage/s3-access-key.service.js';
|
||||
import { s3AccessKeyManagementRateLimiter } from '@/api/middlewares/rate-limiters.js';
|
||||
import { isUnsafeMime, resolveSafeMimeType } from '@/utils/mime-guard.js';
|
||||
|
||||
const router = Router();
|
||||
const auditService = AuditService.getInstance();
|
||||
const storageConfigService = StorageConfigService.getInstance();
|
||||
const s3AccessKeyService = S3AccessKeyService.getInstance();
|
||||
|
||||
const enforceSafeMimeType = async (file: Express.Multer.File) => {
|
||||
if (!file.buffer) {
|
||||
throw new AppError(
|
||||
'In-memory buffer required for MIME validation',
|
||||
500,
|
||||
ERROR_CODES.INTERNAL_ERROR
|
||||
);
|
||||
}
|
||||
// Magic-byte MIME detection: override client-supplied mimetype with the
|
||||
// true detected type. Executable types (HTML, SVG, JS) are normalised to
|
||||
// application/octet-stream before the metadata is written to the DB.
|
||||
file.mimetype = await resolveSafeMimeType(file.buffer, file.mimetype);
|
||||
};
|
||||
|
||||
// Middleware to conditionally apply authentication based on bucket visibility.
|
||||
// This is only attached to object download hand-offs: GET object bytes and
|
||||
// GET/POST download-strategy. Strategy endpoint is GET (POST retained as a
|
||||
// deprecated alias for older SDKs); both are read paths.
|
||||
const conditionalDownloadAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.params.bucketName) {
|
||||
try {
|
||||
const storageService = StorageService.getInstance();
|
||||
const isPublic = await storageService.isBucketPublic(req.params.bucketName);
|
||||
|
||||
if (isPublic) {
|
||||
// Public bucket - skip authentication
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// If error checking bucket, continue with auth requirement
|
||||
}
|
||||
}
|
||||
|
||||
// All other cases require authentication
|
||||
return verifyUser(req, res, next);
|
||||
};
|
||||
|
||||
// GET /api/storage/config - Get storage configuration (requires admin)
|
||||
router.get('/config', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = await storageConfigService.getStorageConfig();
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/storage/config - Update storage configuration (requires admin)
|
||||
router.put('/config', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = updateStorageConfigRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER
|
||||
);
|
||||
}
|
||||
|
||||
const config = await storageConfigService.updateStorageConfig(validation.data);
|
||||
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_STORAGE_CONFIG',
|
||||
module: 'STORAGE',
|
||||
details: { updatedFields: Object.keys(validation.data) },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
successResponse(res, config);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/storage/buckets - List all buckets (requires admin)
|
||||
router.get('/buckets', verifyAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const storageService = StorageService.getInstance();
|
||||
const buckets = await storageService.listBuckets();
|
||||
|
||||
successResponse(res, buckets);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/storage/buckets - Create a new bucket (requires admin)
|
||||
router.post(
|
||||
'/buckets',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createBucketRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER,
|
||||
'Please check the request body, it must conform with the CreateBucketRequest schema.'
|
||||
);
|
||||
}
|
||||
const { bucketName, isPublic } = validation.data;
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
await storageService.createBucket(bucketName, isPublic);
|
||||
|
||||
// Log audit for bucket creation
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_BUCKET',
|
||||
module: 'STORAGE',
|
||||
details: {
|
||||
bucketName,
|
||||
isPublic,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS },
|
||||
'system'
|
||||
);
|
||||
|
||||
const accessInfo = isPublic
|
||||
? 'This is a PUBLIC bucket - objects can be accessed without authentication.'
|
||||
: 'This is a PRIVATE bucket - authentication is required to access objects.';
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
message: 'Bucket created successfully',
|
||||
bucketName,
|
||||
isPublic: isPublic,
|
||||
nextActions: `${accessInfo} You can use /api/storage/buckets/:bucketName/objects/:objectKey to upload an object to the bucket, and /api/storage/buckets/:bucketName/objects to list the objects in the bucket.`,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
next(new AppError(error.message, 409, ERROR_CODES.STORAGE_ALREADY_EXISTS));
|
||||
} else if (error instanceof Error && error.message.includes('Invalid bucket name')) {
|
||||
next(
|
||||
new AppError(
|
||||
error.message,
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER,
|
||||
'Please check the bucket name, it must be a valid bucket name'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/storage/buckets/:bucketName - Update bucket (requires auth)
|
||||
router.patch(
|
||||
'/buckets/:bucketName',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const validation = updateBucketRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER,
|
||||
'Please check the request body, it must conform with the UpdateBucketRequest schema.'
|
||||
);
|
||||
}
|
||||
const { isPublic } = validation.data;
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
await storageService.updateBucketVisibility(bucketName, isPublic);
|
||||
|
||||
// Log audit for bucket update
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'UPDATE_BUCKET',
|
||||
module: 'STORAGE',
|
||||
details: {
|
||||
bucketName,
|
||||
isPublic,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
try {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS, data: { bucketName } },
|
||||
'system'
|
||||
);
|
||||
} catch {
|
||||
// Best-effort notification; do not fail completed storage mutation
|
||||
}
|
||||
|
||||
const accessInfo = isPublic
|
||||
? 'Bucket is now PUBLIC - objects can be accessed without authentication.'
|
||||
: 'Bucket is now PRIVATE - authentication is required to access objects.';
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
message: 'Bucket visibility updated',
|
||||
bucket: bucketName,
|
||||
isPublic: isPublic,
|
||||
nextActions: accessInfo,
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('does not exist')) {
|
||||
next(new AppError(error.message, 404, ERROR_CODES.STORAGE_NOT_FOUND));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/storage/buckets/:bucketName/objects - List objects in bucket.
|
||||
// Visibility is decided by RLS on storage.objects for JWT callers. API-key
|
||||
// callers use the backend pool because they are machine credentials,
|
||||
// not a user identity.
|
||||
router.get(
|
||||
'/buckets/:bucketName/objects',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const prefix = req.query.prefix as string;
|
||||
const searchQuery = req.query.search as string;
|
||||
const limit = Math.min(Math.max(1, parseInt(req.query.limit as string) || 100), 1000);
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const result = await StorageService.getInstance().listObjects(
|
||||
req.user,
|
||||
bucketName,
|
||||
prefix,
|
||||
limit,
|
||||
offset,
|
||||
searchQuery,
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
data: result.objects,
|
||||
pagination: {
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
total: result.total,
|
||||
},
|
||||
nextActions:
|
||||
'You can use PUT /api/storage/buckets/:bucketName/objects/:objectKey to upload with a specific key, or POST /api/storage/buckets/:bucketName/objects to upload with auto-generated key, and GET /api/storage/buckets/:bucketName/objects/:objectKey to download an object.',
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/storage/buckets/:bucketName/objects/:objectKey - Upload object to bucket (requires auth)
|
||||
router.put(
|
||||
'/buckets/:bucketName/objects/*',
|
||||
verifyUser,
|
||||
dynamicUploadSingle('file'),
|
||||
handleUploadError,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const objectKey = req.params[0]; // Everything after objects
|
||||
|
||||
if (!objectKey) {
|
||||
throw new AppError('Object key is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
throw new AppError('File is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
await enforceSafeMimeType(req.file);
|
||||
|
||||
const storedFile = await StorageService.getInstance().putObject(
|
||||
req.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
req.file,
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
try {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS, data: { bucketName } },
|
||||
'system'
|
||||
);
|
||||
} catch {
|
||||
// Best-effort notification; do not fail completed storage mutation
|
||||
}
|
||||
|
||||
successResponse(res, storedFile, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
next(new AppError(error.message, 409, ERROR_CODES.STORAGE_ALREADY_EXISTS));
|
||||
} else if (error instanceof Error && error.message.includes('Invalid')) {
|
||||
next(new AppError(error.message, 400, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/storage/buckets/:bucketName/objects - Upload object with server-generated key (requires auth)
|
||||
router.post(
|
||||
'/buckets/:bucketName/objects',
|
||||
verifyUser,
|
||||
dynamicUploadSingle('file'),
|
||||
handleUploadError,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
throw new AppError('File is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
await enforceSafeMimeType(req.file);
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
|
||||
// Generate a unique key for the object using service
|
||||
const objectKey = storageService.generateObjectKey(req.file.originalname);
|
||||
|
||||
const storedFile = await storageService.putObject(
|
||||
req.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
req.file,
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
try {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS, data: { bucketName } },
|
||||
'system'
|
||||
);
|
||||
} catch {
|
||||
// Best-effort notification; do not fail completed storage mutation
|
||||
}
|
||||
|
||||
successResponse(res, storedFile, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('does not exist')) {
|
||||
next(
|
||||
new AppError(
|
||||
'Bucket does not exist',
|
||||
404,
|
||||
ERROR_CODES.STORAGE_NOT_FOUND,
|
||||
'Create the bucket first using POST /api/storage/buckets'
|
||||
)
|
||||
);
|
||||
} else if (error instanceof Error && error.message.includes('Invalid')) {
|
||||
next(new AppError(error.message, 400, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/storage/buckets/:bucketName/download-strategy/objects/* - Get download URL (presigned or direct)
|
||||
// Read-only strategy hand-off; aligns with S3-style object retrieval semantics.
|
||||
// Strategy lives under a dedicated `/download-strategy/objects/*` path
|
||||
// (rather than `/objects/:objectKey/download-strategy`) so it cannot collide
|
||||
// with the wildcard download route below for object keys that legitimately
|
||||
// contain or end with `download-strategy`.
|
||||
// The wildcard captures the full object key, including `/` (pseudo-folders).
|
||||
const downloadStrategyHandler = async (
|
||||
req: AuthRequest | Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
// For the canonical GET route the wildcard captures the full object key.
|
||||
// For the deprecated POST alias the key is the named `:objectKey` param.
|
||||
const objectKey = req.params[0] ?? req.params.objectKey;
|
||||
|
||||
if (!objectKey) {
|
||||
throw new AppError('Object key is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
|
||||
// RLS-gate the strategy hand-off, same as GET /objects/*. A presigned
|
||||
// URL bypasses RLS at redeem time, so we must verify ownership before
|
||||
// issuing one.
|
||||
const authReq = req as AuthRequest;
|
||||
const metadataRow = await storageService.getObjectMetadataVisible(
|
||||
authReq.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
!!authReq.hasApiKey
|
||||
);
|
||||
if (!metadataRow) {
|
||||
throw new AppError('Object not found', 404, ERROR_CODES.STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Optional caller-supplied TTL (seconds) for the signed URL. Accepted from
|
||||
// the query string (canonical GET) or body (deprecated POST alias). The
|
||||
// service clamps it to a safe range and applies it to private buckets only.
|
||||
// Reject malformed input (e.g. `?expiresIn=abc`) rather than silently
|
||||
// coercing it: `Number('abc')` is NaN and `Number(null)` is 0, either of
|
||||
// which would otherwise hand back a URL with a TTL the caller never asked for.
|
||||
const rawExpiresIn = req.query.expiresIn ?? authReq.body?.expiresIn;
|
||||
let requestedExpiresIn: number | undefined;
|
||||
if (rawExpiresIn !== undefined && rawExpiresIn !== null && rawExpiresIn !== '') {
|
||||
const parsed = Number(rawExpiresIn);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new AppError(
|
||||
'expiresIn must be a finite number of seconds',
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER
|
||||
);
|
||||
}
|
||||
requestedExpiresIn = parsed;
|
||||
}
|
||||
|
||||
const serveMime = metadataRow.mime_type || 'application/octet-stream';
|
||||
const forceAttachment = isUnsafeMime(serveMime);
|
||||
|
||||
const strategy = await storageService.getDownloadStrategy(
|
||||
bucketName,
|
||||
objectKey,
|
||||
requestedExpiresIn,
|
||||
{ asAttachment: forceAttachment, prefetchedMetadata: metadataRow }
|
||||
);
|
||||
|
||||
// Strategy responses embed presigned URLs with short, server-decided
|
||||
// expiries. Prevent intermediaries (proxies, CDNs) from caching this
|
||||
// GET response and replaying expired URLs to later callers.
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
successResponse(res, strategy);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid')) {
|
||||
next(new AppError(error.message, 400, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
router.get(
|
||||
'/buckets/:bucketName/download-strategy/objects/*',
|
||||
conditionalDownloadAuth,
|
||||
downloadStrategyHandler
|
||||
);
|
||||
|
||||
// @deprecated Use GET /buckets/:bucketName/download-strategy/objects/* instead.
|
||||
// Retained at the original path/method for backward compatibility with SDK
|
||||
// releases that already shipped against the POST endpoint. Uses a wildcard
|
||||
// so it matches both single-segment (encodeURIComponent'd) and raw-slash
|
||||
// object keys.
|
||||
router.post(
|
||||
'/buckets/:bucketName/objects/*/download-strategy',
|
||||
conditionalDownloadAuth,
|
||||
downloadStrategyHandler
|
||||
);
|
||||
|
||||
// GET /api/storage/buckets/:bucketName/objects/:objectKey - Download object from bucket (conditional auth)
|
||||
router.get(
|
||||
'/buckets/:bucketName/objects/*',
|
||||
conditionalDownloadAuth,
|
||||
async (req: AuthRequest | Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const objectKey = req.params[0]; // Everything after objects
|
||||
|
||||
if (!objectKey) {
|
||||
throw new AppError('Object key is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
const authReq = req as AuthRequest;
|
||||
const metadataRow = await storageService.getObjectMetadataVisible(
|
||||
authReq.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
!!authReq.hasApiKey
|
||||
);
|
||||
if (!metadataRow) {
|
||||
throw new AppError('Object not found', 404, ERROR_CODES.STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const serveMime = metadataRow.mime_type || 'application/octet-stream';
|
||||
const forceAttachment = isUnsafeMime(serveMime);
|
||||
|
||||
const strategy = await storageService.getDownloadStrategy(bucketName, objectKey, undefined, {
|
||||
asAttachment: forceAttachment,
|
||||
prefetchedMetadata: metadataRow,
|
||||
});
|
||||
if (strategy.method === 'presigned') {
|
||||
return res.redirect(strategy.url);
|
||||
}
|
||||
|
||||
const result = await storageService.getObject(
|
||||
authReq.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
!!authReq.hasApiKey,
|
||||
metadataRow
|
||||
);
|
||||
if (!result) {
|
||||
throw new AppError('Object not found', 404, ERROR_CODES.STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const { file, metadata } = result;
|
||||
|
||||
// Set appropriate headers
|
||||
const responseMime = metadata.mimeType || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', responseMime);
|
||||
res.setHeader('Content-Length', file.length.toString());
|
||||
// Defence-in-depth: even if a legacy upload somehow stored an executable
|
||||
// MIME type, force the browser to download rather than render it inline.
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (isUnsafeMime(responseMime)) {
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
}
|
||||
|
||||
// Send object content
|
||||
res.send(file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid')) {
|
||||
next(new AppError(error.message, 400, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/storage/buckets/:bucketName - Delete entire bucket (requires auth)
|
||||
router.delete(
|
||||
'/buckets/:bucketName',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const storageService = StorageService.getInstance();
|
||||
const deleted = await storageService.deleteBucket(bucketName);
|
||||
|
||||
if (!deleted) {
|
||||
throw new AppError('Bucket not found or already empty', 404, ERROR_CODES.STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Log audit for bucket deletion
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_BUCKET',
|
||||
module: 'STORAGE',
|
||||
details: {
|
||||
bucketName,
|
||||
},
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(
|
||||
res,
|
||||
{
|
||||
message: 'Bucket deleted successfully',
|
||||
nextActions:
|
||||
'You can use POST /api/storage/buckets to create a new bucket, and GET /api/storage/buckets/:bucketName/objects to list the objects in the bucket.',
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/storage/buckets/:bucketName/objects - Delete multiple objects from bucket (requires auth)
|
||||
router.delete(
|
||||
'/buckets/:bucketName/objects',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const validation = deleteObjectsRequestSchema.safeParse(req.body);
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER
|
||||
);
|
||||
}
|
||||
|
||||
const result = await StorageService.getInstance().deleteObjects(
|
||||
req.user,
|
||||
bucketName,
|
||||
validation.data.keys,
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
successResponse(res, result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/storage/buckets/:bucketName/objects/:objectKey - Delete object from bucket (requires auth)
|
||||
router.delete(
|
||||
'/buckets/:bucketName/objects/*',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const objectKey = req.params[0]; // Everything after objects
|
||||
|
||||
if (!objectKey) {
|
||||
throw new AppError('Object key is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
const deleted = await StorageService.getInstance().deleteObject(
|
||||
req.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
throw new AppError('Object not found', 404, ERROR_CODES.STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
successResponse(res, { message: 'Object deleted successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid')) {
|
||||
next(new AppError(error.message, 400, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/storage/buckets/:bucketName/upload-strategy - Get upload strategy (presigned or direct)
|
||||
router.post(
|
||||
'/buckets/:bucketName/upload-strategy',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName } = req.params;
|
||||
const { filename, contentType, size } = req.body;
|
||||
|
||||
if (!filename) {
|
||||
throw new AppError('Filename is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
const requestedType =
|
||||
typeof contentType === 'string' && contentType.trim()
|
||||
? contentType
|
||||
: 'application/octet-stream';
|
||||
const safeContentType = isUnsafeMime(requestedType)
|
||||
? 'application/octet-stream'
|
||||
: requestedType;
|
||||
|
||||
const strategy = await StorageService.getInstance().getUploadStrategy(
|
||||
req.user,
|
||||
bucketName,
|
||||
{ filename, contentType: safeContentType, size },
|
||||
!!req.hasApiKey,
|
||||
safeContentType
|
||||
);
|
||||
|
||||
successResponse(res, strategy);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('does not exist')) {
|
||||
next(new AppError(error.message, 404, ERROR_CODES.STORAGE_NOT_FOUND));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/storage/buckets/:bucketName/objects/:objectKey/confirm-upload - Confirm presigned upload
|
||||
router.post(
|
||||
'/buckets/:bucketName/objects/:objectKey/confirm-upload',
|
||||
verifyUser,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { bucketName, objectKey } = req.params;
|
||||
const { size, etag } = req.body;
|
||||
let { contentType } = req.body;
|
||||
if (contentType !== undefined && contentType !== null) {
|
||||
const typeStr =
|
||||
typeof contentType === 'string' && contentType.trim()
|
||||
? contentType
|
||||
: 'application/octet-stream';
|
||||
contentType = isUnsafeMime(typeStr) ? 'application/octet-stream' : typeStr;
|
||||
}
|
||||
|
||||
if (!size) {
|
||||
throw new AppError('Size is required', 400, ERROR_CODES.STORAGE_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
const storageService = StorageService.getInstance();
|
||||
const fileInfo = await storageService.confirmUpload(
|
||||
req.user,
|
||||
bucketName,
|
||||
objectKey,
|
||||
{
|
||||
size,
|
||||
contentType,
|
||||
etag,
|
||||
},
|
||||
!!req.hasApiKey
|
||||
);
|
||||
|
||||
try {
|
||||
const socket = SocketManager.getInstance();
|
||||
socket.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.BUCKETS, data: { bucketName } },
|
||||
'system'
|
||||
);
|
||||
} catch {
|
||||
// Best-effort notification; do not fail completed storage mutation
|
||||
}
|
||||
|
||||
successResponse(res, fileInfo, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
next(new AppError(error.message, 404, ERROR_CODES.STORAGE_NOT_FOUND));
|
||||
} else if (error instanceof Error && error.message.includes('already confirmed')) {
|
||||
next(new AppError(error.message, 409, ERROR_CODES.STORAGE_ALREADY_EXISTS));
|
||||
} else if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('exceeds the configured maximum')
|
||||
) {
|
||||
next(new AppError(error.message, 413, ERROR_CODES.STORAGE_INVALID_PARAMETER));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// S3 Protocol — Gateway Config + Access Key Management (admin only)
|
||||
// Per-IP rate limiting applied across all three access-key endpoints since
|
||||
// they mint / revoke long-lived credentials.
|
||||
// ============================================================================
|
||||
|
||||
// GET /api/storage/s3/config - Return the gateway endpoint + signing region.
|
||||
// Endpoint is assembled from VITE_API_BASE_URL (the externally-reachable base
|
||||
// URL clients use for this backend) plus the fixed /storage/v1/s3 path. The
|
||||
// signing region is the value the SigV4 middleware validates against; clients
|
||||
// must sign with exactly this value.
|
||||
router.get('/s3/config', verifyAdmin, (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const base = (process.env.VITE_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
const endpoint = base ? `${base}/storage/v1/s3` : '/storage/v1/s3';
|
||||
const region = process.env.AWS_REGION || 'us-east-2';
|
||||
successResponse(res, { endpoint, region });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/storage/s3/access-keys - Create a new access key. Plaintext secret
|
||||
// is returned ONCE in the response and never again.
|
||||
router.post(
|
||||
'/s3/access-keys',
|
||||
s3AccessKeyManagementRateLimiter,
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validation = createS3AccessKeyRequestSchema.safeParse(req.body ?? {});
|
||||
if (!validation.success) {
|
||||
throw new AppError(
|
||||
validation.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '),
|
||||
400,
|
||||
ERROR_CODES.STORAGE_INVALID_PARAMETER
|
||||
);
|
||||
}
|
||||
const result = await s3AccessKeyService.create(validation.data);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'CREATE_S3_ACCESS_KEY',
|
||||
module: 'STORAGE',
|
||||
details: { accessKeyId: result.accessKeyId },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
successResponse(res, result, 201);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/storage/s3/access-keys - List all access keys (no secrets)
|
||||
router.get(
|
||||
'/s3/access-keys',
|
||||
s3AccessKeyManagementRateLimiter,
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const keys = await s3AccessKeyService.list();
|
||||
successResponse(res, keys);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/storage/s3/access-keys/:id - Revoke an access key
|
||||
router.delete(
|
||||
'/s3/access-keys/:id',
|
||||
s3AccessKeyManagementRateLimiter,
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await s3AccessKeyService.delete(req.params.id);
|
||||
await auditService.log({
|
||||
actor: req.hasApiKey ? 'api-key' : req.user?.id,
|
||||
action: 'DELETE_S3_ACCESS_KEY',
|
||||
module: 'STORAGE',
|
||||
details: { id: req.params.id },
|
||||
ip_address: req.ip,
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { router as storageRouter };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Router, NextFunction, Response } from 'express';
|
||||
import {
|
||||
verifyCloudBackend,
|
||||
verifyApiKey,
|
||||
verifyAdmin,
|
||||
AuthRequest,
|
||||
} from '@/api/middlewares/auth.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { ServerEvents } from '@/types/socket.js';
|
||||
import { UsageService } from '@/services/usage/usage.service.js';
|
||||
import { successResponse } from '@/utils/response.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
|
||||
export const usageRouter = Router();
|
||||
const usageService = UsageService.getInstance();
|
||||
|
||||
// Create MCP tool usage record
|
||||
usageRouter.post(
|
||||
'/mcp',
|
||||
verifyApiKey,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { tool_name, success = true } = req.body;
|
||||
|
||||
if (!tool_name) {
|
||||
throw new AppError('tool_name is required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Create MCP usage record via service
|
||||
const result = await usageService.recordMCPUsage(tool_name, success);
|
||||
|
||||
// Broadcast MCP tool usage to frontend via socket
|
||||
const socketService = SocketManager.getInstance();
|
||||
|
||||
socketService.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.MCP_CONNECTED,
|
||||
{ tool_name, created_at: result.created_at },
|
||||
'system'
|
||||
);
|
||||
|
||||
successResponse(res, { success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get MCP usage records
|
||||
usageRouter.get(
|
||||
'/mcp',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { limit = '5', success = 'true' } = req.query;
|
||||
|
||||
// Get MCP usage records via service
|
||||
const records = await usageService.getMCPUsage(parseInt(limit as string), success === 'true');
|
||||
|
||||
successResponse(res, { records });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get usage statistics (called by cloud backend)
|
||||
usageRouter.get(
|
||||
'/stats',
|
||||
verifyCloudBackend,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { start_date, end_date } = req.query;
|
||||
|
||||
if (!start_date || !end_date) {
|
||||
throw new AppError('start_date and end_date are required', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
// Get usage statistics via service
|
||||
const stats = await usageService.getUsageStats(
|
||||
new Date(start_date as string),
|
||||
new Date(end_date as string)
|
||||
);
|
||||
|
||||
successResponse(res, stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { razorpayWebhookRouter } from './razorpay.routes.js';
|
||||
import { stripeWebhookRouter } from './stripe.routes.js';
|
||||
import { vercelWebhookRouter } from './vercel.routes.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/stripe', stripeWebhookRouter);
|
||||
router.use('/razorpay', razorpayWebhookRouter);
|
||||
router.use('/vercel', vercelWebhookRouter);
|
||||
|
||||
export { router as webhooksRouter };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import { parseZodSchema } from '@/utils/zod.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { RazorpayWebhookService } from '@/services/payments/razorpay/webhook.service.js';
|
||||
import { ERROR_CODES, razorpayWebhookParamsSchema } from '@insforge/shared-schemas';
|
||||
|
||||
const router = Router();
|
||||
const webhookService = RazorpayWebhookService.getInstance();
|
||||
|
||||
router.post('/:environment', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { environment } = parseZodSchema(razorpayWebhookParamsSchema, req.params);
|
||||
|
||||
const signature = req.headers['x-razorpay-signature'];
|
||||
if (!signature || typeof signature !== 'string') {
|
||||
throw new AppError('Missing X-Razorpay-Signature header', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const rawBodyBuffer = req.body;
|
||||
if (!Buffer.isBuffer(rawBodyBuffer)) {
|
||||
throw new AppError('Missing raw Razorpay webhook body', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const headerEventId = req.headers['x-razorpay-event-id'];
|
||||
const result = await webhookService.handleRazorpayWebhook(
|
||||
environment,
|
||||
rawBodyBuffer,
|
||||
signature,
|
||||
typeof headerEventId === 'string' ? headerEventId : undefined
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as razorpayWebhookRouter };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES, stripeWebhookParamsSchema } from '@insforge/shared-schemas';
|
||||
import { StripeWebhookService } from '@/services/payments/stripe/webhook.service.js';
|
||||
|
||||
const router = Router();
|
||||
const webhookService = StripeWebhookService.getInstance();
|
||||
|
||||
export function normalizeStripeWebhookError(error: unknown) {
|
||||
if (error instanceof Error && error.name === 'StripeSignatureVerificationError') {
|
||||
return new AppError(error.message, 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe webhook endpoint
|
||||
* POST /api/webhooks/stripe/:environment
|
||||
*
|
||||
* Receives Stripe test/live account events and updates payment runtime projections.
|
||||
* Verifies the request using Stripe's signature over the raw request body.
|
||||
*/
|
||||
router.post('/:environment', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const paramsValidation = stripeWebhookParamsSchema.safeParse(req.params);
|
||||
if (!paramsValidation.success) {
|
||||
throw new AppError('Invalid Stripe environment', 400, ERROR_CODES.INVALID_INPUT);
|
||||
}
|
||||
|
||||
const signature = req.headers['stripe-signature'];
|
||||
if (typeof signature !== 'string') {
|
||||
throw new AppError('Missing stripe-signature header', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!Buffer.isBuffer(req.body)) {
|
||||
throw new AppError(
|
||||
'Stripe webhook requires raw request body',
|
||||
400,
|
||||
ERROR_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
const result = await webhookService.handleStripeWebhook(
|
||||
paramsValidation.data.environment,
|
||||
req.body,
|
||||
signature
|
||||
);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(normalizeStripeWebhookError(error));
|
||||
}
|
||||
});
|
||||
|
||||
export { router as stripeWebhookRouter };
|
||||
@@ -0,0 +1,124 @@
|
||||
import crypto from 'crypto';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DeploymentService } from '@/services/deployments/deployment.service.js';
|
||||
import { SecretService } from '@/services/secrets/secret.service.js';
|
||||
import { AppError } from '@/utils/errors.js';
|
||||
import { ERROR_CODES } from '@insforge/shared-schemas';
|
||||
import {
|
||||
VERCEL_EVENT_TO_STATUS,
|
||||
type VercelWebhookPayload,
|
||||
type VercelDeploymentEventType,
|
||||
} from '@/types/webhooks.js';
|
||||
import { SocketManager } from '@/infra/socket/socket.manager.js';
|
||||
import { DataUpdateResourceType, ServerEvents } from '@/types/socket.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
|
||||
const router = Router();
|
||||
const deploymentService = DeploymentService.getInstance();
|
||||
const secretService = SecretService.getInstance();
|
||||
|
||||
/**
|
||||
* Vercel webhook endpoint
|
||||
* POST /api/webhooks/vercel
|
||||
*
|
||||
* Receives deployment events from Vercel and updates the database accordingly.
|
||||
* Verifies the request using HMAC-SHA1 signature in x-vercel-signature header.
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const signature = req.headers['x-vercel-signature'] as string | undefined;
|
||||
|
||||
if (!signature) {
|
||||
throw new AppError('Missing x-vercel-signature header', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get the webhook secret from secrets service
|
||||
const webhookSecret = await secretService.getSecretByKey('VERCEL_WEBHOOK_SECRET');
|
||||
|
||||
if (!webhookSecret) {
|
||||
logger.error('VERCEL_WEBHOOK_SECRET not found in secrets');
|
||||
throw new AppError('Webhook not configured', 500, ERROR_CODES.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
// req.body is raw Buffer (express.raw middleware applied in server.ts)
|
||||
const rawBody = req.body as Buffer;
|
||||
|
||||
// Verify the signature using HMAC-SHA1 on original bytes
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha1', webhookSecret)
|
||||
.update(rawBody)
|
||||
.digest('hex');
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
const signatureBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
|
||||
if (
|
||||
signatureBuffer.length !== expectedBuffer.length ||
|
||||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
|
||||
) {
|
||||
logger.warn('Invalid Vercel webhook signature');
|
||||
throw new AppError('Invalid signature', 401, ERROR_CODES.AUTH_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Parse the webhook payload after signature verification
|
||||
const webhookPayload = JSON.parse(rawBody.toString()) as VercelWebhookPayload;
|
||||
const eventType = webhookPayload.type;
|
||||
|
||||
// Check if this is a deployment event we handle
|
||||
if (!(eventType in VERCEL_EVENT_TO_STATUS)) {
|
||||
logger.info('Ignoring unhandled Vercel webhook event', { eventType });
|
||||
return res.status(200).json({ received: true, handled: false });
|
||||
}
|
||||
|
||||
const status = VERCEL_EVENT_TO_STATUS[eventType as VercelDeploymentEventType];
|
||||
const deploymentId = webhookPayload.payload.deployment.id;
|
||||
const url = webhookPayload.payload.deployment.url
|
||||
? `https://${webhookPayload.payload.deployment.url}`
|
||||
: null;
|
||||
|
||||
// Update the deployment in our database
|
||||
const deployment = await deploymentService.updateDeploymentFromWebhook(
|
||||
deploymentId,
|
||||
status,
|
||||
url,
|
||||
{
|
||||
webhookEventId: webhookPayload.id,
|
||||
webhookEventType: eventType,
|
||||
target: webhookPayload.payload.target,
|
||||
projectId: webhookPayload.payload.project?.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!deployment) {
|
||||
// Deployment not found in our database - this is ok, might be from another source
|
||||
logger.info('Deployment not found for webhook, ignoring', { deploymentId });
|
||||
return res.status(200).json({ received: true, handled: false });
|
||||
}
|
||||
|
||||
// Broadcast deployment status change to frontend via socket
|
||||
try {
|
||||
const socketService = SocketManager.getInstance();
|
||||
socketService.broadcastToRoom(
|
||||
'role:project_admin',
|
||||
ServerEvents.DATA_UPDATE,
|
||||
{ resource: DataUpdateResourceType.DEPLOYMENTS },
|
||||
'system'
|
||||
);
|
||||
} catch {
|
||||
// Best-effort notification; do not fail webhook response
|
||||
}
|
||||
|
||||
logger.info('Vercel webhook processed successfully', {
|
||||
eventType,
|
||||
deploymentId,
|
||||
status,
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true, handled: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export { router as vercelWebhookRouter };
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { verifyAdmin, AuthRequest } from '@/api/middlewares/auth.js';
|
||||
import { WebscraperService } from '@/services/webscraper/webscraper.service.js';
|
||||
|
||||
export const webscraperRouter = Router();
|
||||
const service = WebscraperService.getInstance();
|
||||
|
||||
// GET /api/webscraper/apify/connection
|
||||
webscraperRouter.get(
|
||||
'/apify/connection',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const conn = await service.getApifyConnection();
|
||||
if (!conn) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json({ connected: true, connection: conn });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/webscraper/apify/connection
|
||||
webscraperRouter.delete(
|
||||
'/apify/connection',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await service.disconnectApify();
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function parseLimit(raw: unknown, fallback: number, max: number): number {
|
||||
const n = parseInt(String(raw ?? fallback), 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(n, max);
|
||||
}
|
||||
|
||||
// GET /api/webscraper/apify/token — runtime token accessor. Admin-gated: it
|
||||
// returns the user's live Apify OAuth token, so it must NOT be reachable with an
|
||||
// anon key. verifyAdmin accepts the project `ik_` admin key that edge functions
|
||||
// get injected, plus project_admin JWTs.
|
||||
webscraperRouter.get(
|
||||
'/apify/token',
|
||||
verifyAdmin,
|
||||
async (_req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tok = await service.getApifyToken();
|
||||
if (!tok) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json(tok);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/webscraper/apify/runs?limit=
|
||||
webscraperRouter.get(
|
||||
'/apify/runs',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getApifyRuns(parseLimit(req.query.limit, 10, 200));
|
||||
if (!data) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/webscraper/apify/actors?limit= — actor-first list (recently used)
|
||||
webscraperRouter.get(
|
||||
'/apify/actors',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getApifyActors(parseLimit(req.query.limit, 20, 100));
|
||||
if (!data) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/webscraper/apify/datasets?limit= — dataset-first list (recently created)
|
||||
webscraperRouter.get(
|
||||
'/apify/datasets',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getApifyDatasets(parseLimit(req.query.limit, 20, 100));
|
||||
if (!data) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/webscraper/apify/data?limit= — latest run's dataset preview
|
||||
webscraperRouter.get(
|
||||
'/apify/data',
|
||||
verifyAdmin,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = await service.getApifyLatestData(parseLimit(req.query.limit, 5, 20));
|
||||
if (!data) {
|
||||
res.status(404).json({ error: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import { parseTrustProxySetting, TrustProxySetting } from '../../utils/trust-proxy.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const envPaths = [
|
||||
path.resolve(__dirname, '../../../../.env'),
|
||||
path.resolve(__dirname, '../../../.env'),
|
||||
path.resolve(process.cwd(), '.env'),
|
||||
path.resolve(process.cwd(), '../.env'),
|
||||
];
|
||||
const envPath = envPaths.find((p) => fs.existsSync(p));
|
||||
if (envPath) {
|
||||
dotenv.config({ path: envPath });
|
||||
} else {
|
||||
dotenv.config();
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
app: {
|
||||
port: number;
|
||||
jwtSecret: string;
|
||||
apiKey: string;
|
||||
logLevel: string;
|
||||
};
|
||||
cloud: {
|
||||
storageBucket: string;
|
||||
instanceProfile: string;
|
||||
apiHost: string;
|
||||
appKey: string;
|
||||
cloudFrontUrl: string | undefined;
|
||||
cloudFrontKeyPairId: string | undefined;
|
||||
cloudFrontPrivateKey: string | undefined;
|
||||
projectId: string | undefined;
|
||||
};
|
||||
denoSubhosting: {
|
||||
token: string;
|
||||
organizationId: string;
|
||||
domain: string;
|
||||
};
|
||||
fly: {
|
||||
apiToken: string;
|
||||
org: string;
|
||||
domain: string;
|
||||
};
|
||||
server: {
|
||||
maxJsonBodySize: string;
|
||||
maxUrlencodedBodySize: string;
|
||||
maxFileSize: number | undefined;
|
||||
maxFilesPerField: number;
|
||||
logsDir: string;
|
||||
trustProxy: TrustProxySetting;
|
||||
};
|
||||
database: {
|
||||
host: string;
|
||||
port: number;
|
||||
name: string;
|
||||
user: string;
|
||||
password: string;
|
||||
dir: string;
|
||||
postgrestBaseUrl: string;
|
||||
};
|
||||
auth: {
|
||||
rootAdminUsername: string;
|
||||
rootAdminPassword: string;
|
||||
accessApiKey: string | undefined;
|
||||
accessAnonKey: string | undefined;
|
||||
};
|
||||
storage: {
|
||||
s3Bucket: string | undefined;
|
||||
appKey: string;
|
||||
parentAppKey: string | undefined;
|
||||
awsRegion: string;
|
||||
storageDir: string;
|
||||
s3AccessKeyId: string | undefined;
|
||||
s3SecretAccessKey: string | undefined;
|
||||
awsAccessKeyId: string | undefined;
|
||||
awsSecretAccessKey: string | undefined;
|
||||
s3EndpointUrl: string | undefined;
|
||||
s3ForcePathStyle: boolean;
|
||||
awsConfigBucket: string;
|
||||
awsConfigRegion: string;
|
||||
maxS3UploadSize: number;
|
||||
};
|
||||
functions: {
|
||||
denoRuntimeUrl: string;
|
||||
};
|
||||
deployments: {
|
||||
vercelToken: string | undefined;
|
||||
vercelTeamId: string | undefined;
|
||||
vercelProjectId: string | undefined;
|
||||
maxDeploymentFiles: number;
|
||||
maxDeploymentTotalBytes: number;
|
||||
maxDeploymentFileBytes: number;
|
||||
};
|
||||
ai: {
|
||||
openrouterApiKey: string | undefined;
|
||||
};
|
||||
telemetry: {
|
||||
disabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function parseEnvInt(val: string | undefined, fallback: number): number {
|
||||
if (!val) return fallback;
|
||||
const parsed = parseInt(val, 10);
|
||||
if (isNaN(parsed) || parsed <= 0 || !Number.isSafeInteger(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseEnvBool(val: string | undefined): boolean {
|
||||
if (!val) return false;
|
||||
return ['1', 'true', 'yes', 'on'].includes(val.trim().toLowerCase());
|
||||
}
|
||||
|
||||
const AWS_MAX_SINGLE_PUT_BYTES = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
function parseEnvBytes(val: string | undefined, fallback: number): number {
|
||||
if (!val) return fallback;
|
||||
if (!/^\d+$/.test(val)) return fallback;
|
||||
const parsed = Number(val);
|
||||
if (!Number.isFinite(parsed) || !Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(parsed, AWS_MAX_SINGLE_PUT_BYTES);
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const logsDir = process.env.LOGS_DIR || path.join(process.cwd(), 'logs');
|
||||
|
||||
return {
|
||||
app: {
|
||||
port: parseEnvInt(process.env.PORT, 7130),
|
||||
jwtSecret: process.env.JWT_SECRET || '',
|
||||
apiKey: process.env.ACCESS_API_KEY || 'your_api_key',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
},
|
||||
cloud: {
|
||||
storageBucket: process.env.AWS_S3_BUCKET || 'insforge-test-bucket',
|
||||
instanceProfile: process.env.AWS_INSTANCE_PROFILE_NAME || 'insforge-instance-profile',
|
||||
apiHost: process.env.CLOUD_API_HOST || 'https://api.insforge.dev',
|
||||
projectId: process.env.PROJECT_ID || undefined,
|
||||
appKey: process.env.APP_KEY || 'default-app-key',
|
||||
cloudFrontUrl: process.env.AWS_CLOUDFRONT_URL || undefined,
|
||||
cloudFrontKeyPairId: process.env.AWS_CLOUDFRONT_KEY_PAIR_ID || undefined,
|
||||
cloudFrontPrivateKey: process.env.AWS_CLOUDFRONT_PRIVATE_KEY || undefined,
|
||||
},
|
||||
denoSubhosting: {
|
||||
// Deno Deploy (v2) credentials. Renamed from DENO_SUBHOSTING_* so an
|
||||
// instance's .env can carry both the legacy Subhosting pair (for older
|
||||
// OSS versions) and this pair side by side during the v1→v2 migration.
|
||||
token: process.env.DENO_DEPLOY_TOKEN || '',
|
||||
organizationId: process.env.DENO_DEPLOY_ORG_ID || '',
|
||||
// Public function domain. On Deno v2 this is the CloudFront proxy domain
|
||||
// (`function2.insforge.app`) that forwards `{appkey}.function2.insforge.app`
|
||||
// → `{appkey}.insforge.deno.net`. Overridable so the cloud control-plane can
|
||||
// pin v1 (`functions.insforge.app`) vs v2 per deployment. See
|
||||
// docs/deno-subhosting.md §4.1.
|
||||
domain: process.env.FUNCTIONS_DOMAIN || 'function2.insforge.app',
|
||||
},
|
||||
fly: {
|
||||
apiToken: process.env.FLY_API_TOKEN || '',
|
||||
org: process.env.FLY_ORG || '',
|
||||
domain: process.env.COMPUTE_DOMAIN || '',
|
||||
},
|
||||
server: {
|
||||
maxJsonBodySize: process.env.MAX_JSON_BODY_SIZE || '100mb',
|
||||
maxUrlencodedBodySize: process.env.MAX_URLENCODED_BODY_SIZE || '10mb',
|
||||
maxFileSize: (() => {
|
||||
const parsed = parseInt(process.env.MAX_FILE_SIZE || '', 10);
|
||||
return isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
||||
})(),
|
||||
maxFilesPerField: parseEnvInt(process.env.MAX_FILES_PER_FIELD, 10),
|
||||
logsDir,
|
||||
trustProxy: parseTrustProxySetting(process.env.TRUST_PROXY),
|
||||
},
|
||||
database: {
|
||||
host: process.env.POSTGRES_HOST || 'localhost',
|
||||
port: parseEnvInt(process.env.POSTGRES_PORT, 5432),
|
||||
name: process.env.POSTGRES_DB || 'insforge',
|
||||
user: process.env.POSTGRES_USER || 'postgres',
|
||||
password: process.env.POSTGRES_PASSWORD || 'postgres',
|
||||
dir: process.env.DATABASE_DIR || path.join(__dirname, '../../data'),
|
||||
postgrestBaseUrl: process.env.POSTGREST_BASE_URL || 'http://localhost:5430',
|
||||
},
|
||||
auth: {
|
||||
rootAdminUsername: process.env.ROOT_ADMIN_USERNAME || process.env.ADMIN_EMAIL || '',
|
||||
rootAdminPassword: process.env.ROOT_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || '',
|
||||
accessApiKey: process.env.ACCESS_API_KEY || undefined,
|
||||
accessAnonKey: process.env.ACCESS_ANON_KEY || undefined,
|
||||
},
|
||||
storage: {
|
||||
s3Bucket: process.env.AWS_S3_BUCKET || undefined,
|
||||
appKey: process.env.APP_KEY || 'local',
|
||||
parentAppKey: process.env.PARENT_APP_KEY?.trim() || undefined,
|
||||
awsRegion: process.env.AWS_REGION || 'us-east-2',
|
||||
storageDir: process.env.STORAGE_DIR || path.resolve(process.cwd(), 'insforge-storage'),
|
||||
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID || undefined,
|
||||
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY || undefined,
|
||||
awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID || undefined,
|
||||
awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || undefined,
|
||||
s3EndpointUrl: process.env.S3_ENDPOINT_URL || undefined,
|
||||
// Default true (MinIO etc.). Set S3_FORCE_PATH_STYLE=false for providers
|
||||
// that require virtual-hosted-style addressing (Tencent COS, Aliyun OSS).
|
||||
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
|
||||
awsConfigBucket: process.env.AWS_CONFIG_BUCKET || 'insforge-config',
|
||||
awsConfigRegion: process.env.AWS_CONFIG_REGION || 'us-east-2',
|
||||
maxS3UploadSize: parseEnvBytes(process.env.S3_MAX_OBJECT_SIZE_BYTES, 5 * 1024 * 1024 * 1024),
|
||||
},
|
||||
functions: {
|
||||
denoRuntimeUrl: process.env.DENO_RUNTIME_URL || 'http://localhost:7133',
|
||||
},
|
||||
deployments: {
|
||||
vercelToken: process.env.VERCEL_TOKEN || undefined,
|
||||
vercelTeamId: process.env.VERCEL_TEAM_ID || undefined,
|
||||
vercelProjectId: process.env.VERCEL_PROJECT_ID || undefined,
|
||||
maxDeploymentFiles: parseEnvInt(process.env.MAX_DEPLOYMENT_FILES, 5000),
|
||||
maxDeploymentTotalBytes: parseEnvInt(
|
||||
process.env.MAX_DEPLOYMENT_TOTAL_BYTES,
|
||||
100 * 1024 * 1024
|
||||
),
|
||||
maxDeploymentFileBytes: parseEnvInt(process.env.MAX_DEPLOYMENT_FILE_BYTES, 100 * 1024 * 1024),
|
||||
},
|
||||
ai: {
|
||||
openrouterApiKey: process.env.OPENROUTER_API_KEY || undefined,
|
||||
},
|
||||
telemetry: {
|
||||
disabled: parseEnvBool(process.env.INSFORGE_TELEMETRY_DISABLED),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const appConfig: AppConfig = loadConfig();
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Pool, Client } from 'pg';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { DatabaseMetadataSchema } from '@insforge/shared-schemas';
|
||||
import pgFormat from 'pg-format';
|
||||
import { buildQualifiedTableKey, DEFAULT_DATABASE_SCHEMA } from '@/services/database/helpers.js';
|
||||
import { appConfig } from '@/infra/config/app.config.js';
|
||||
import logger from '@/utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiry: number;
|
||||
}
|
||||
|
||||
export class DatabaseManager {
|
||||
private static instance: DatabaseManager;
|
||||
private pool!: Pool;
|
||||
private dataDir: string;
|
||||
|
||||
private static readonly COLUMN_TYPE_CACHE_TTL = 5 * 60 * 1000;
|
||||
private static columnTypeCache = new Map<string, CacheEntry<Record<string, string>>>();
|
||||
private static readonly MAX_CACHE_SIZE = 100;
|
||||
/**
|
||||
* Maximum entries for table row counts.
|
||||
* Bounded at 1000 per workspace/process to prevent memory leaks in multi-tenant or multi-schema deployments.
|
||||
*/
|
||||
private static readonly MAX_TABLE_COUNT_CACHE_SIZE = 1000;
|
||||
private static readonly TABLE_COUNT_CACHE_TTL = 60 * 1000;
|
||||
private static tableCountCache = new Map<string, { count: number; timestamp: number }>();
|
||||
|
||||
private constructor() {
|
||||
this.dataDir = appConfig.database.dir;
|
||||
}
|
||||
|
||||
static getInstance(): DatabaseManager {
|
||||
if (!DatabaseManager.instance) {
|
||||
DatabaseManager.instance = new DatabaseManager();
|
||||
}
|
||||
return DatabaseManager.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
|
||||
this.pool = new Pool({
|
||||
host: appConfig.database.host,
|
||||
port: appConfig.database.port,
|
||||
database: appConfig.database.name,
|
||||
user: appConfig.database.user,
|
||||
password: appConfig.database.password,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
static async getColumnTypeMap(
|
||||
tableName: string,
|
||||
schemaName: string = DEFAULT_DATABASE_SCHEMA
|
||||
): Promise<Record<string, string>> {
|
||||
const cacheKey = buildQualifiedTableKey(tableName, schemaName);
|
||||
const cached = DatabaseManager.columnTypeCache.get(cacheKey);
|
||||
if (cached && cached.expiry > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const instance = DatabaseManager.getInstance();
|
||||
const client = await instance.pool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT column_name, data_type, udt_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2`,
|
||||
[schemaName, tableName]
|
||||
);
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
const dataType = row.data_type.toLowerCase();
|
||||
map[row.column_name] = dataType === 'user-defined' ? row.udt_name.toLowerCase() : dataType;
|
||||
}
|
||||
|
||||
DatabaseManager.setBoundedCache(
|
||||
DatabaseManager.columnTypeCache,
|
||||
DatabaseManager.MAX_CACHE_SIZE,
|
||||
cacheKey,
|
||||
{ data: map, expiry: Date.now() + DatabaseManager.COLUMN_TYPE_CACHE_TTL }
|
||||
);
|
||||
return map;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts an entry into a bounded cache with FIFO eviction.
|
||||
* When the cache reaches maxSize, the oldest entry (first insertion-order key) is removed
|
||||
* before adding the new entry, preventing unbounded memory growth.
|
||||
*
|
||||
* @param cache - The Map to insert into
|
||||
* @param maxSize - Maximum number of entries allowed
|
||||
* @param key - Cache key
|
||||
* @param entry - Value to cache
|
||||
*/
|
||||
private static setBoundedCache<V>(
|
||||
cache: Map<string, V>,
|
||||
maxSize: number,
|
||||
key: string,
|
||||
entry: V
|
||||
): void {
|
||||
if (cache.size >= maxSize && !cache.has(key)) {
|
||||
const first = cache.keys().next().value;
|
||||
if (first !== undefined) {
|
||||
cache.delete(first);
|
||||
}
|
||||
}
|
||||
cache.set(key, entry);
|
||||
}
|
||||
|
||||
static clearColumnTypeCache(
|
||||
tableName?: string,
|
||||
schemaName: string = DEFAULT_DATABASE_SCHEMA
|
||||
): void {
|
||||
if (tableName) {
|
||||
DatabaseManager.columnTypeCache.delete(buildQualifiedTableKey(tableName, schemaName));
|
||||
} else {
|
||||
DatabaseManager.columnTypeCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async getUserTables(): Promise<string[]> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT table_name as name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`
|
||||
);
|
||||
return result.rows.map((row: { name: string }) => row.name);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<DatabaseMetadataSchema> {
|
||||
const [allTables, databaseSize] = await Promise.all([
|
||||
this.getUserTables(),
|
||||
this.getDatabaseSizeInGB(),
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
const requestCounts = new Map<string, number>();
|
||||
const missingOrExpired: string[] = [];
|
||||
|
||||
for (const tableName of allTables) {
|
||||
const cacheKey = buildQualifiedTableKey(tableName, 'public');
|
||||
const cached = DatabaseManager.tableCountCache.get(cacheKey);
|
||||
if (cached && now - cached.timestamp < DatabaseManager.TABLE_COUNT_CACHE_TTL) {
|
||||
requestCounts.set(cacheKey, cached.count);
|
||||
} else {
|
||||
missingOrExpired.push(tableName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingOrExpired.length > 0) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
const unionQuery = missingOrExpired
|
||||
.map((tableName) =>
|
||||
pgFormat(
|
||||
'SELECT %L as table_name, COUNT(*) as count FROM %I.%I',
|
||||
tableName,
|
||||
'public',
|
||||
tableName
|
||||
)
|
||||
)
|
||||
.join(' UNION ALL ');
|
||||
|
||||
const queryResult = await client.query(unionQuery);
|
||||
const nowAfterQuery = Date.now();
|
||||
|
||||
// 1. Resolve all database counts into the request-local map first
|
||||
for (const row of queryResult.rows) {
|
||||
const cacheKey = buildQualifiedTableKey(row.table_name, 'public');
|
||||
requestCounts.set(cacheKey, Number(row.count));
|
||||
}
|
||||
|
||||
// 2. Perform all cache mutations second
|
||||
for (const row of queryResult.rows) {
|
||||
const cacheKey = buildQualifiedTableKey(row.table_name, 'public');
|
||||
const count = requestCounts.get(cacheKey) ?? Number(row.count);
|
||||
DatabaseManager.setBoundedCache(
|
||||
DatabaseManager.tableCountCache,
|
||||
DatabaseManager.MAX_TABLE_COUNT_CACHE_SIZE,
|
||||
cacheKey,
|
||||
{ count, timestamp: nowAfterQuery }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to batch query exact table counts:', { error });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
const tableMetadatas = allTables.map((tableName) => {
|
||||
const cacheKey = buildQualifiedTableKey(tableName, 'public');
|
||||
return {
|
||||
tableName,
|
||||
recordCount: requestCounts.get(cacheKey) ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tables: tableMetadatas,
|
||||
totalSizeInGB: databaseSize,
|
||||
hint: 'To retrieve detailed schema information for a specific table, call the get-table-schema tool with the table name.',
|
||||
};
|
||||
}
|
||||
|
||||
async getDatabaseSizeInGB(): Promise<number> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
// Query PostgreSQL for database size
|
||||
const result = await client.query(`SELECT pg_database_size(current_database()) as size`);
|
||||
|
||||
// PostgreSQL returns size in bytes, convert to GB
|
||||
return (result.rows[0]?.size || 0) / (1024 * 1024 * 1024);
|
||||
} catch {
|
||||
return 0;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
getPool(): Pool {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dedicated client for operations that can't use pooled connections (e.g., LISTEN/NOTIFY)
|
||||
*/
|
||||
createClient(): Client {
|
||||
return new Client({
|
||||
host: appConfig.database.host,
|
||||
port: appConfig.database.port,
|
||||
database: appConfig.database.name,
|
||||
user: appConfig.database.user,
|
||||
password: appConfig.database.password,
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Migration: 000 - Create base system tables
|
||||
-- This migration creates all the initial tables that the application needs
|
||||
|
||||
-- System configuration
|
||||
CREATE TABLE IF NOT EXISTS _config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- App metadata
|
||||
CREATE TABLE IF NOT EXISTS _metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Storage buckets table to track bucket-level settings
|
||||
CREATE TABLE IF NOT EXISTS _storage_buckets (
|
||||
name TEXT PRIMARY KEY,
|
||||
public BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Storage files table
|
||||
CREATE TABLE IF NOT EXISTS _storage (
|
||||
bucket TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mime_type TEXT,
|
||||
uploaded_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (bucket, key),
|
||||
FOREIGN KEY (bucket) REFERENCES _storage_buckets(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- MCP usage tracking table
|
||||
CREATE TABLE IF NOT EXISTS _mcp_usage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tool_name VARCHAR(255) NOT NULL,
|
||||
success BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- AI configurations table
|
||||
CREATE TABLE IF NOT EXISTS _ai_configs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
modality VARCHAR(255) NOT NULL,
|
||||
provider VARCHAR(255) NOT NULL,
|
||||
model_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
system_prompt TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS _ai_usage (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
config_id UUID NOT NULL,
|
||||
input_tokens INT,
|
||||
output_tokens INT,
|
||||
image_count INT,
|
||||
image_resolution TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
FOREIGN KEY (config_id) REFERENCES _ai_configs(id) ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
-- Indexes for AI tables
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_config_id ON _ai_usage(config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON _ai_usage(created_at DESC);
|
||||
|
||||
-- Index for efficient date range queries
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_usage_created_at ON _mcp_usage(created_at DESC);
|
||||
|
||||
-- Edge functions
|
||||
CREATE TABLE IF NOT EXISTS _edge_functions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
code TEXT NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
deployed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Auth Tables (2-table structure)
|
||||
-- User table
|
||||
CREATE TABLE IF NOT EXISTS _user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT, -- NULL for OAuth-only users
|
||||
name TEXT,
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Account table (for OAuth connections)
|
||||
CREATE TABLE IF NOT EXISTS _account (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES _user(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL, -- OAuth provider name: 'google', 'github', etc.
|
||||
provider_account_id TEXT NOT NULL, -- User's unique ID on the provider's system
|
||||
access_token TEXT, -- OAuth access token for making API calls to provider
|
||||
refresh_token TEXT, -- OAuth refresh token for renewing access
|
||||
provider_data JSONB, -- OAuth provider's user profile
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(provider, provider_account_id)
|
||||
);
|
||||
|
||||
-- Create update timestamp function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Create triggers for updated_at
|
||||
DROP TRIGGER IF EXISTS update__config_updated_at ON _config;
|
||||
CREATE TRIGGER update__config_updated_at BEFORE UPDATE ON _config
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update__metadata_updated_at ON _metadata;
|
||||
CREATE TRIGGER update__metadata_updated_at BEFORE UPDATE ON _metadata
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update__edge_functions_updated_at ON _edge_functions;
|
||||
CREATE TRIGGER update__edge_functions_updated_at BEFORE UPDATE ON _edge_functions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Insert initial metadata
|
||||
INSERT INTO _metadata (key, value) VALUES ('version', '1.0.0')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
|
||||
INSERT INTO _metadata (key, value) VALUES ('created_at', NOW()::TEXT)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Migration: 001 - Create helper functions for RLS
|
||||
-- These functions extract user information from JWT claims
|
||||
|
||||
-- Function to get current user ID from JWT
|
||||
CREATE OR REPLACE FUNCTION public.uid()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
nullif(
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.sub', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'sub')
|
||||
),
|
||||
''
|
||||
)::uuid
|
||||
$$;
|
||||
|
||||
-- Function to get current user role from JWT
|
||||
CREATE OR REPLACE FUNCTION public.role()
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.role', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'role')
|
||||
)::text
|
||||
$$;
|
||||
|
||||
-- Function to get current user email from JWT
|
||||
CREATE OR REPLACE FUNCTION public.email()
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.email', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'email')
|
||||
)::text
|
||||
$$;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration: 002 - Rename authentication tables to better auth naming convention
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Handle _account to _oauth_connections
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_account' AND table_schema = 'public') THEN
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_oauth_connections' AND table_schema = 'public') THEN
|
||||
-- Both exist, drop the old one
|
||||
DROP TABLE _account CASCADE;
|
||||
ELSE
|
||||
-- Only old exists, rename it
|
||||
ALTER TABLE _account RENAME TO _oauth_connections;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Handle _user to _accounts
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_user' AND table_schema = 'public') THEN
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_accounts' AND table_schema = 'public') THEN
|
||||
-- Both exist, drop the old one
|
||||
DROP TABLE _user CASCADE;
|
||||
ELSE
|
||||
-- Only old exists, rename it
|
||||
ALTER TABLE _user RENAME TO _accounts;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Migration: 003 - Create users table for user profiles
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY REFERENCES _accounts(id) ON DELETE CASCADE,
|
||||
nickname TEXT,
|
||||
avatar_url TEXT,
|
||||
bio TEXT,
|
||||
birthday DATE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Check and create policies only if they don't exist
|
||||
-- Allow everyone to read users table
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'users'
|
||||
AND policyname = 'Enable read access for all users'
|
||||
) THEN
|
||||
CREATE POLICY "Enable read access for all users" ON users
|
||||
FOR SELECT
|
||||
USING (true); -- Allow all reads
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'users'
|
||||
AND policyname = 'Disable delete for users'
|
||||
) THEN
|
||||
CREATE POLICY "Disable delete for users" ON users
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (false);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'users'
|
||||
AND policyname = 'Enable update for users based on user_id'
|
||||
) THEN
|
||||
CREATE POLICY "Enable update for users based on user_id" ON users
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (uid() = id)
|
||||
WITH CHECK (uid() = id); -- make sure only the owner can update
|
||||
END IF;
|
||||
|
||||
-- Enable Row Level Security on the users table
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Grant permissions to anon and authenticated roles
|
||||
GRANT SELECT ON users TO anon;
|
||||
GRANT SELECT, UPDATE ON users TO authenticated;
|
||||
END $$;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Migration: 004 - add reload_postgrest_schema function.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Create a function to reload PostgREST schema
|
||||
CREATE OR REPLACE FUNCTION reload_postgrest_schema()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $reload_function$
|
||||
BEGIN
|
||||
-- Method 1: Use NOTIFY to signal PostgREST to reload schema
|
||||
-- PostgREST listens to 'pgrst' channel for schema changes
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
RAISE NOTICE 'PostgREST schema reload notification sent';
|
||||
END
|
||||
$reload_function$;
|
||||
|
||||
-- Grant execute permission to project_admin and authenticated users
|
||||
GRANT EXECUTE ON FUNCTION reload_postgrest_schema() TO project_admin;
|
||||
GRANT EXECUTE ON FUNCTION reload_postgrest_schema() TO authenticated;
|
||||
|
||||
RAISE NOTICE 'PostgREST schema reload function created successfully';
|
||||
END $$;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration: 005 - Enable project admin modify any users.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Create policy to allow project_admin to update any user
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'users'
|
||||
AND policyname = 'Allow project_admin to update any user'
|
||||
) THEN
|
||||
CREATE POLICY "Allow project_admin to update any user" ON users
|
||||
FOR UPDATE
|
||||
TO project_admin
|
||||
USING (true)
|
||||
WITH CHECK (true); -- Ensure project_admin always can update
|
||||
END IF;
|
||||
|
||||
-- Grant necessary permissions
|
||||
GRANT SELECT, UPDATE ON users TO project_admin;
|
||||
|
||||
-- Notify PostgREST to reload schema after policy changes
|
||||
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'reload_postgrest_schema') THEN
|
||||
PERFORM reload_postgrest_schema();
|
||||
RAISE NOTICE 'PostgREST schema reload requested after migration';
|
||||
ELSE
|
||||
RAISE WARNING 'PostgREST reload function not found - please restart PostgREST manually';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Migration project-admin-update-users completed successfully';
|
||||
END $$;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migration: 006 - Modify AI usage table
|
||||
-- This migration modifies the _ai_usage table to:
|
||||
-- 1. Change foreign key constraint on config_id to SET NULL
|
||||
-- 2. Make config_id nullable
|
||||
-- 3. Add model_id column
|
||||
|
||||
-- Drop existing foreign key constraint
|
||||
ALTER TABLE _ai_usage
|
||||
DROP CONSTRAINT IF EXISTS _ai_usage_config_id_fkey;
|
||||
|
||||
-- Make config_id nullable
|
||||
ALTER TABLE _ai_usage
|
||||
ALTER COLUMN config_id DROP NOT NULL;
|
||||
|
||||
-- Add new foreign key constraint with SET NULL on delete
|
||||
ALTER TABLE _ai_usage
|
||||
ADD CONSTRAINT _ai_usage_config_id_fkey
|
||||
FOREIGN KEY (config_id) REFERENCES _ai_configs(id) ON DELETE SET NULL;
|
||||
|
||||
-- Add new columns for model identification
|
||||
ALTER TABLE _ai_usage
|
||||
ADD COLUMN IF NOT EXISTS model_id VARCHAR(255);
|
||||
|
||||
-- Create indexes for the new columns
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_model_id ON _ai_usage(model_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop the _metadata table if it exists
|
||||
DROP TABLE IF EXISTS _metadata CASCADE;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Migration: 008 - Create new system tables and rename OAuth connections table
|
||||
|
||||
-- 1. Create _secrets table for storing application secrets
|
||||
CREATE TABLE IF NOT EXISTS _secrets (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
value_ciphertext TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. Create _oauth_configs table for OAuth provider configurations
|
||||
CREATE TABLE IF NOT EXISTS _oauth_configs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
provider TEXT UNIQUE NOT NULL,
|
||||
client_id TEXT,
|
||||
secret_id UUID REFERENCES _secrets(id) ON DELETE RESTRICT,
|
||||
scopes TEXT[],
|
||||
redirect_uri TEXT,
|
||||
use_shared_key BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. Create _audit_logs table for storing admin operation logs
|
||||
CREATE TABLE IF NOT EXISTS _audit_logs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
module TEXT NOT NULL,
|
||||
details JSONB,
|
||||
ip_address INET,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 4. Rename _oauth_connections to _account_providers
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_oauth_connections' AND table_schema = 'public') THEN
|
||||
IF EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_name = '_account_providers' AND table_schema = 'public') THEN
|
||||
-- _account_providers already exists, just drop _oauth_connections
|
||||
DROP TABLE _oauth_connections CASCADE;
|
||||
ELSE
|
||||
-- _account_providers doesn't exist, rename _oauth_connections to _account_providers
|
||||
ALTER TABLE _oauth_connections RENAME TO _account_providers;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 5. Drop the old _config system table
|
||||
DROP TABLE IF EXISTS _config CASCADE;
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_name ON _secrets(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_configs_provider ON _oauth_configs(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON _audit_logs(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_module ON _audit_logs(module);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON _audit_logs(created_at DESC);
|
||||
|
||||
-- Add triggers for updated_at
|
||||
DROP TRIGGER IF EXISTS update__secrets_updated_at ON _secrets;
|
||||
CREATE TRIGGER update__secrets_updated_at BEFORE UPDATE ON _secrets
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update__oauth_configs_updated_at ON _oauth_configs;
|
||||
CREATE TRIGGER update__oauth_configs_updated_at BEFORE UPDATE ON _oauth_configs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update__audit_logs_updated_at ON _audit_logs;
|
||||
CREATE TRIGGER update__audit_logs_updated_at BEFORE UPDATE ON _audit_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Migration: 009 - Create function secrets table for edge functions environment variables
|
||||
-- This table stores encrypted secrets that are injected into edge functions as Deno.env variables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS _function_secrets (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
key VARCHAR(255) UNIQUE NOT NULL,
|
||||
value_ciphertext TEXT NOT NULL, -- Encrypted value using AES-256-GCM
|
||||
is_reserved BOOLEAN DEFAULT FALSE, -- System-reserved keys that can't be modified/deleted
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for faster key lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_function_secrets_key ON _function_secrets(key);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update__function_secrets_updated_at ON _function_secrets;
|
||||
CREATE TRIGGER update__function_secrets_updated_at BEFORE UPDATE ON _function_secrets
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Note: Reserved system secrets will be initialized by the application on startup
|
||||
|
||||
-- Rename _edge_functions table to functions
|
||||
ALTER TABLE IF EXISTS _edge_functions RENAME TO _functions;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Migration: 010 - Modify AI configurations table to support input/output modalities
|
||||
-- This migration modifies the _ai_configs table to:
|
||||
-- 1. Add new columns: input_modality and output_modality (TEXT arrays)
|
||||
-- 2. Migrate existing modality data to input_modality
|
||||
-- 3. Set default output_modality based on existing modality
|
||||
-- 4. Drop the old modality column
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Add new columns for input and output modalities
|
||||
ALTER TABLE _ai_configs
|
||||
ADD COLUMN IF NOT EXISTS input_modality TEXT[] DEFAULT '{text}';
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ADD COLUMN IF NOT EXISTS output_modality TEXT[] DEFAULT '{text}';
|
||||
|
||||
-- Check if modality column exists and migrate data if it does
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = '_ai_configs'
|
||||
AND column_name = 'modality'
|
||||
) THEN
|
||||
-- Migrate existing modality data to input_modality
|
||||
-- For most cases, we'll set input_modality to the existing modality
|
||||
-- and output_modality to the same value, only supporting text and image
|
||||
UPDATE _ai_configs
|
||||
SET
|
||||
input_modality = CASE
|
||||
WHEN modality = 'multi' THEN '{text,image}'::TEXT[]
|
||||
WHEN modality = 'image' THEN '{text,image}'::TEXT[]
|
||||
ELSE ARRAY[modality]::TEXT[]
|
||||
END,
|
||||
output_modality = CASE
|
||||
WHEN modality = 'multi' THEN '{text,image}'::TEXT[]
|
||||
WHEN modality = 'text' THEN '{text}'::TEXT[]
|
||||
WHEN modality = 'image' THEN '{text,image}'::TEXT[]
|
||||
ELSE '{text}'::TEXT[]
|
||||
END
|
||||
WHERE input_modality = '{text}' OR input_modality IS NULL;
|
||||
END IF;
|
||||
|
||||
-- Make the new columns NOT NULL after migration
|
||||
ALTER TABLE _ai_configs
|
||||
ALTER COLUMN input_modality SET NOT NULL;
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ALTER COLUMN output_modality SET NOT NULL;
|
||||
|
||||
-- Drop the old modality column
|
||||
ALTER TABLE _ai_configs
|
||||
DROP COLUMN IF EXISTS modality;
|
||||
|
||||
-- Create indexes for the new TEXT array columns for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_configs_input_modality ON _ai_configs USING GIN (input_modality);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_configs_output_modality ON _ai_configs USING GIN (output_modality);
|
||||
|
||||
-- Drop existing constraints if they exist, then add them
|
||||
ALTER TABLE _ai_configs
|
||||
DROP CONSTRAINT IF EXISTS check_input_modality_not_empty;
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ADD CONSTRAINT check_input_modality_not_empty
|
||||
CHECK (array_length(input_modality, 1) > 0);
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
DROP CONSTRAINT IF EXISTS check_output_modality_not_empty;
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ADD CONSTRAINT check_output_modality_not_empty
|
||||
CHECK (array_length(output_modality, 1) > 0);
|
||||
|
||||
-- Drop existing constraints if they exist, then add them
|
||||
ALTER TABLE _ai_configs
|
||||
DROP CONSTRAINT IF EXISTS check_input_modality_valid;
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ADD CONSTRAINT check_input_modality_valid
|
||||
CHECK (
|
||||
input_modality <@ '{text,image}'::TEXT[]
|
||||
);
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
DROP CONSTRAINT IF EXISTS check_output_modality_valid;
|
||||
|
||||
ALTER TABLE _ai_configs
|
||||
ADD CONSTRAINT check_output_modality_valid
|
||||
CHECK (
|
||||
output_modality <@ '{text,image}'::TEXT[]
|
||||
);
|
||||
|
||||
END $$;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration: 011 - Drop function secrets table and update main secrets table
|
||||
-- This migration is part of the refactoring to unify all secrets management
|
||||
|
||||
-- 1. Drop the _function_secrets table (replaced by main _secrets table)
|
||||
DROP TRIGGER IF EXISTS update__function_secrets_updated_at ON _function_secrets;
|
||||
DROP INDEX IF EXISTS idx_function_secrets_key;
|
||||
DROP TABLE IF EXISTS _function_secrets;
|
||||
|
||||
-- 2. Add is_reserved column to _secrets table
|
||||
ALTER TABLE _secrets
|
||||
ADD COLUMN IF NOT EXISTS is_reserved BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- 3. Rename name column to key
|
||||
ALTER TABLE _secrets
|
||||
RENAME COLUMN name TO key;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 012 - Add uploaded_by column to _storage table
|
||||
-- This migration adds a foreign key relationship to track which account uploaded each file
|
||||
|
||||
ALTER TABLE _storage
|
||||
ADD COLUMN uploaded_by UUID REFERENCES _accounts(id) ON DELETE SET NULL;
|
||||
|
||||
-- Create an index for better query performance when filtering by uploader
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_uploaded_by ON _storage(uploaded_by);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Migration: 013 - Create auth schema and copy helper functions
|
||||
-- Creates auth schema if it doesn't exist and copies JWT helper functions
|
||||
|
||||
-- Create auth schema if not exists
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- Function to get current user ID from JWT (in auth schema)
|
||||
CREATE OR REPLACE FUNCTION auth.uid()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
nullif(
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.sub', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'sub')
|
||||
),
|
||||
''
|
||||
)::uuid
|
||||
$$;
|
||||
|
||||
-- Function to get current user role from JWT (in auth schema)
|
||||
CREATE OR REPLACE FUNCTION auth.role()
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.role', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'role')
|
||||
)::text
|
||||
$$;
|
||||
|
||||
-- Function to get current user email from JWT (in auth schema)
|
||||
CREATE OR REPLACE FUNCTION auth.email()
|
||||
RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
coalesce(
|
||||
current_setting('request.jwt.claim.email', true),
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'email')
|
||||
)::text
|
||||
$$;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 014 - Add updated_at trigger to users table
|
||||
-- Adds the updated_at trigger to the users table for automatic timestamp management
|
||||
|
||||
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Migration: 015 - Create email OTP verification table and email auth configs
|
||||
-- This migration creates:
|
||||
-- 1. _email_otps: Stores one-time tokens for email verification purposes
|
||||
-- - Supports both short numeric codes (6 digits) for manual entry
|
||||
-- - Supports long cryptographic tokens (64 chars) for magic links
|
||||
-- - Uses dual hashing strategy:
|
||||
-- * NUMERIC_CODE (6 digits): Bcrypt hash (slow, defense against brute force)
|
||||
-- * LINK_TOKEN (64 hex chars): SHA-256 hash (fast, enables direct O(1) lookup)
|
||||
-- 2. _auth_configs: Stores email authentication configuration (single-row table)
|
||||
|
||||
-- 1. Create email OTP verification table
|
||||
CREATE TABLE IF NOT EXISTS _email_otps (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
otp_hash TEXT NOT NULL, -- Hash of OTP: bcrypt for NUMERIC_CODE, SHA-256 for LINK_TOKEN
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
attempts_count INTEGER DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (email, purpose) -- Only one active token per email/purpose combination
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_email_otps_email_purpose ON _email_otps(email, purpose);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_otps_expires_at ON _email_otps(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_otps_otp_hash ON _email_otps(otp_hash); -- For direct LINK_TOKEN lookup
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update__email_otps_updated_at ON _email_otps;
|
||||
CREATE TRIGGER update__email_otps_updated_at
|
||||
BEFORE UPDATE ON _email_otps
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 2. Create email authentication configuration table (single-row design)
|
||||
-- This table stores global email authentication settings for the project
|
||||
CREATE TABLE IF NOT EXISTS _auth_configs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
require_email_verification BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
password_min_length INTEGER DEFAULT 6 NOT NULL CHECK (password_min_length >= 4 AND password_min_length <= 128),
|
||||
require_number BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
require_lowercase BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
require_uppercase BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
require_special_char BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
verify_email_redirect_to TEXT, -- Custom URL to redirect after successful email verification (defaults to no redirect if NULL)
|
||||
reset_password_redirect_to TEXT, -- Custom URL to redirect after successful password reset (defaults to no redirect if NULL)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ensure only one row exists (singleton pattern)
|
||||
-- This constraint prevents multiple configuration rows
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_configs_singleton ON _auth_configs ((1));
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update__auth_configs_updated_at ON _auth_configs;
|
||||
CREATE TRIGGER update__auth_configs_updated_at
|
||||
BEFORE UPDATE ON _auth_configs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Migration 016: Update _email_otps and _auth_configs tables
|
||||
--
|
||||
-- Changes:
|
||||
-- 1. _email_otps table:
|
||||
-- - Remove attempts_count column (brute force protection moved to API rate limiter)
|
||||
-- 2. _auth_configs table:
|
||||
-- - Remove verify_email_redirect_to and reset_password_redirect_to columns
|
||||
-- - Add verify_email_method and reset_password_method columns (code or link)
|
||||
-- - Add sign_in_redirect_to column
|
||||
|
||||
-- Update _email_otps: Remove attempts_count column
|
||||
ALTER TABLE _email_otps DROP COLUMN IF EXISTS attempts_count;
|
||||
|
||||
-- Update _auth_configs: Remove old redirect columns
|
||||
ALTER TABLE _auth_configs
|
||||
DROP COLUMN IF EXISTS verify_email_redirect_to,
|
||||
DROP COLUMN IF EXISTS reset_password_redirect_to;
|
||||
|
||||
-- Add new columns to _auth_configs
|
||||
-- Note: DEFAULT 'code' NOT NULL ensures existing rows automatically get 'code' value
|
||||
ALTER TABLE _auth_configs
|
||||
ADD COLUMN IF NOT EXISTS verify_email_method TEXT DEFAULT 'code' NOT NULL CHECK (verify_email_method IN ('code', 'link')),
|
||||
ADD COLUMN IF NOT EXISTS reset_password_method TEXT DEFAULT 'code' NOT NULL CHECK (reset_password_method IN ('code', 'link')),
|
||||
ADD COLUMN IF NOT EXISTS sign_in_redirect_to TEXT;
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Migration 017: Create Realtime Schema
|
||||
--
|
||||
-- Creates the realtime schema with:
|
||||
-- 1. channels table - Channel definitions with webhook configuration
|
||||
-- 2. messages table - All realtime messages with delivery statistics
|
||||
-- 3. publish() function - Called by developer triggers to publish events
|
||||
--
|
||||
-- Permission Model (Supabase pattern):
|
||||
-- - SELECT on channels = 'subscribe' permission (subscribe to channel)
|
||||
-- - INSERT on messages = 'publish' permission (publish to channel)
|
||||
-- Developers define RLS policies on channels/messages table to control access.
|
||||
|
||||
-- ============================================================================
|
||||
-- CREATE SCHEMA
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS realtime;
|
||||
|
||||
-- ============================================================================
|
||||
-- CHANNELS TABLE
|
||||
-- ============================================================================
|
||||
-- Stores channel definitions with delivery configuration.
|
||||
-- RLS policies control subscribe permissions.
|
||||
-- - SELECT policy = 'subscribe' permission (can subscribe to channel)
|
||||
-- Channel names use : as separator and % for wildcards (LIKE pattern).
|
||||
-- Examples: "orders", "order:%", "chat:%:messages"
|
||||
|
||||
CREATE TABLE IF NOT EXISTS realtime.channels (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
|
||||
-- Channel name pattern (e.g., "orders", "order:%", "chat:%:messages")
|
||||
-- Convention: use : as separator, % for wildcards (LIKE pattern)
|
||||
pattern TEXT UNIQUE NOT NULL,
|
||||
|
||||
-- Human-readable description
|
||||
description TEXT,
|
||||
|
||||
-- Webhook URLs to POST events to (NULL or empty array = no webhooks)
|
||||
webhook_urls TEXT[],
|
||||
|
||||
-- Whether this channel is active
|
||||
enabled BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- MESSAGES TABLE
|
||||
-- ============================================================================
|
||||
-- Stores all realtime messages published through the system.
|
||||
-- RLS policies on this table control publish permissions:
|
||||
-- - INSERT policy = 'publish' permission (can publish to channel)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS realtime.messages (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
|
||||
-- Event metadata
|
||||
event_name TEXT NOT NULL,
|
||||
|
||||
-- Channel reference (SET NULL on delete to preserve history)
|
||||
channel_id UUID REFERENCES realtime.channels(id) ON DELETE SET NULL,
|
||||
channel_name TEXT NOT NULL, -- Denormalized for query convenience after channel deletion
|
||||
|
||||
-- Event payload (stored for audit/replay purposes)
|
||||
payload JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
|
||||
-- Sender information
|
||||
-- 'system' = triggered by database trigger (via publish() function)
|
||||
-- 'user' = published by client via WebSocket
|
||||
sender_type TEXT DEFAULT 'system' NOT NULL CHECK (sender_type IN ('system', 'user')),
|
||||
sender_id UUID, -- User ID for 'user' type, NULL for 'system' type
|
||||
|
||||
-- Delivery statistics for WebSocket
|
||||
ws_audience_count INTEGER DEFAULT 0 NOT NULL, -- How many clients were subscribed
|
||||
|
||||
-- Delivery statistics for Webhooks
|
||||
wh_audience_count INTEGER DEFAULT 0 NOT NULL, -- How many webhook URLs configured
|
||||
wh_delivered_count INTEGER DEFAULT 0 NOT NULL, -- How many succeeded (2xx response)
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- INDEXES
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_channels_pattern ON realtime.channels(pattern);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_channels_enabled ON realtime.channels(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_messages_channel_id ON realtime.messages(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_messages_channel_name ON realtime.messages(channel_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_messages_created_at ON realtime.messages(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_messages_event_name ON realtime.messages(event_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_messages_sender ON realtime.messages(sender_type, sender_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- UPDATED_AT TRIGGER
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION realtime.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channels_updated_at ON realtime.channels;
|
||||
CREATE TRIGGER trg_channels_updated_at
|
||||
BEFORE UPDATE ON realtime.channels
|
||||
FOR EACH ROW EXECUTE FUNCTION realtime.update_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- ROW LEVEL SECURITY (DISABLED BY DEFAULT)
|
||||
-- ============================================================================
|
||||
-- RLS is disabled by default for the best developer experience.
|
||||
-- Channels and messages are open to all authenticated/anon users out of the box.
|
||||
--
|
||||
-- To enable access control, developers can:
|
||||
-- 1. Enable RLS: ALTER TABLE realtime.channels ENABLE ROW LEVEL SECURITY;
|
||||
-- 2. Add policies using the helper function realtime.channel_name()
|
||||
--
|
||||
-- See documentation for policy examples.
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER FUNCTIONS FOR RLS POLICIES
|
||||
-- ============================================================================
|
||||
|
||||
-- Returns the channel name being accessed (set by backend during permission checks)
|
||||
-- Developers use this in policies instead of writing current_setting directly
|
||||
CREATE OR REPLACE FUNCTION realtime.channel_name()
|
||||
RETURNS TEXT AS $$
|
||||
SELECT current_setting('realtime.channel_name', true);
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- ============================================================================
|
||||
-- PUBLISH FUNCTION
|
||||
-- ============================================================================
|
||||
-- Called by developer triggers to publish events to channels.
|
||||
-- This function can only be executed by the backend (SECURITY DEFINER).
|
||||
--
|
||||
-- Usage in a trigger:
|
||||
-- PERFORM realtime.publish(
|
||||
-- 'order:' || NEW.id::text, -- channel name (resolved instance)
|
||||
-- 'order_updated', -- event name
|
||||
-- jsonb_build_object('id', NEW.id, 'status', NEW.status) -- payload
|
||||
-- );
|
||||
|
||||
CREATE OR REPLACE FUNCTION realtime.publish(
|
||||
p_channel_name TEXT,
|
||||
p_event_name TEXT,
|
||||
p_payload JSONB
|
||||
)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_channel_id UUID;
|
||||
v_message_id UUID;
|
||||
BEGIN
|
||||
-- Find matching channel: exact match first, then wildcard pattern match
|
||||
-- For wildcard patterns like "order:%", check if p_channel_name LIKE pattern
|
||||
SELECT id INTO v_channel_id
|
||||
FROM realtime.channels
|
||||
WHERE enabled = TRUE
|
||||
AND (pattern = p_channel_name OR p_channel_name LIKE pattern)
|
||||
ORDER BY pattern = p_channel_name DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- If no channel found, raise a warning and return NULL
|
||||
IF v_channel_id IS NULL THEN
|
||||
RAISE WARNING 'Realtime: No matching channel found for "%"', p_channel_name;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Insert message record (system-triggered, so sender_type = 'system')
|
||||
INSERT INTO realtime.messages (
|
||||
event_name,
|
||||
channel_id,
|
||||
channel_name,
|
||||
payload,
|
||||
sender_type
|
||||
) VALUES (
|
||||
p_event_name,
|
||||
v_channel_id,
|
||||
p_channel_name,
|
||||
p_payload,
|
||||
'system'
|
||||
)
|
||||
RETURNING id INTO v_message_id;
|
||||
|
||||
RETURN v_message_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Revoke execute from public, only backend can call this
|
||||
REVOKE ALL ON FUNCTION realtime.publish FROM PUBLIC;
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGER FOR PG_NOTIFY
|
||||
-- ============================================================================
|
||||
-- Trigger function that sends pg_notify for every new message (both system and client).
|
||||
|
||||
CREATE OR REPLACE FUNCTION realtime.notify_on_message_insert()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Send only message_id to bypass pg_notify 8KB payload limit
|
||||
-- Backend will fetch full message from DB
|
||||
PERFORM pg_notify('realtime_message', NEW.id::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger on messages table
|
||||
DROP TRIGGER IF EXISTS trg_message_notify ON realtime.messages;
|
||||
CREATE TRIGGER trg_message_notify
|
||||
AFTER INSERT ON realtime.messages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION realtime.notify_on_message_insert();
|
||||
|
||||
-- ============================================================================
|
||||
-- GRANTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Grant schema access to both authenticated and anonymous users
|
||||
GRANT USAGE ON SCHEMA realtime TO authenticated, anon;
|
||||
|
||||
-- Grant SELECT on channels table (allows subscribe)
|
||||
GRANT SELECT ON realtime.channels TO authenticated, anon;
|
||||
|
||||
-- Grant INSERT on messages table (allows publish)
|
||||
GRANT INSERT ON realtime.messages TO authenticated, anon;
|
||||
|
||||
-- Grant execution permission on helper function (used when RLS is enabled)
|
||||
GRANT EXECUTE ON FUNCTION realtime.channel_name() TO authenticated, anon;
|
||||
@@ -0,0 +1,441 @@
|
||||
-- Migration: 018 - Create system and auth schemas, move and rename system tables
|
||||
-- This migration creates dedicated schemas for internal system and auth tables,
|
||||
-- moves tables into them, and removes the underscore prefix from table names.
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 1: SYSTEM SCHEMA (secrets, audit_logs, mcp_usage)
|
||||
-- Note: migrations table is handled by bootstrap/bootstrap-migrations.js before this runs
|
||||
-- ============================================================================
|
||||
|
||||
-- 1.1 Create the system schema (may already exist from bootstrap)
|
||||
CREATE SCHEMA IF NOT EXISTS system;
|
||||
|
||||
-- 1.2 Move _secrets table to system schema and rename to 'secrets'
|
||||
-- First, drop the foreign key constraint from _oauth_configs that references _secrets
|
||||
ALTER TABLE public._oauth_configs
|
||||
DROP CONSTRAINT IF EXISTS _oauth_configs_secret_id_fkey;
|
||||
|
||||
-- Move and rename the _secrets table to system.secrets
|
||||
ALTER TABLE public._secrets SET SCHEMA system;
|
||||
ALTER TABLE system._secrets RENAME TO secrets;
|
||||
|
||||
-- Note: We'll recreate the FK constraint after moving _oauth_configs to auth schema
|
||||
|
||||
-- 1.3 Move _audit_logs table to system schema and rename to 'audit_logs'
|
||||
ALTER TABLE public._audit_logs SET SCHEMA system;
|
||||
ALTER TABLE system._audit_logs RENAME TO audit_logs;
|
||||
|
||||
-- 1.4 Move _mcp_usage table to system schema and rename to 'mcp_usage'
|
||||
ALTER TABLE public._mcp_usage SET SCHEMA system;
|
||||
ALTER TABLE system._mcp_usage RENAME TO mcp_usage;
|
||||
|
||||
-- Note: system schema is internal and should NOT be exposed to PUBLIC.
|
||||
-- Access is controlled through the application's database connection.
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 2: AUTH SCHEMA (users, user_providers, configs, oauth_configs, email_otps)
|
||||
-- ============================================================================
|
||||
|
||||
-- 2.1 Create the auth schema
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
-- 2.2 Drop all foreign key constraints that reference tables being moved
|
||||
-- FK: _account_providers.user_id -> _accounts(id)
|
||||
ALTER TABLE public._account_providers
|
||||
DROP CONSTRAINT IF EXISTS _account_providers_user_id_fkey;
|
||||
|
||||
-- FK: users.id -> _accounts(id) (public users table references _accounts)
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_id_fkey;
|
||||
|
||||
-- FK: _storage.uploaded_by -> _accounts(id)
|
||||
ALTER TABLE public._storage
|
||||
DROP CONSTRAINT IF EXISTS _storage_uploaded_by_fkey;
|
||||
|
||||
-- 2.3 Move _accounts table to auth schema and rename to 'users'
|
||||
ALTER TABLE public._accounts SET SCHEMA auth;
|
||||
ALTER TABLE auth._accounts RENAME TO users;
|
||||
|
||||
-- 2.4 Move _account_providers table to auth schema and rename to 'user_providers'
|
||||
ALTER TABLE public._account_providers SET SCHEMA auth;
|
||||
ALTER TABLE auth._account_providers RENAME TO user_providers;
|
||||
|
||||
-- 2.5 Recreate FK: auth.user_providers.user_id -> auth.users(id)
|
||||
ALTER TABLE auth.user_providers
|
||||
ADD CONSTRAINT user_providers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
-- 2.6 Add profile and metadata JSONB columns to auth.users BEFORE migrating data
|
||||
-- profile: stores user profile data (name, avatar_url, bio, etc.)
|
||||
-- metadata: reserved for system use (device ID, login IP, etc.)
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS profile JSONB DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb;
|
||||
|
||||
-- 2.7 Migrate data from public.users into auth.users.profile
|
||||
-- Priority for name: public.users.nickname first, then auth.users.name as fallback
|
||||
-- Also migrate: avatar_url, bio, birthday from public.users
|
||||
UPDATE auth.users AS au
|
||||
SET profile = jsonb_strip_nulls(jsonb_build_object(
|
||||
'name', COALESCE(pu.nickname, au.name),
|
||||
'avatar_url', pu.avatar_url,
|
||||
'bio', pu.bio,
|
||||
'birthday', pu.birthday
|
||||
))
|
||||
FROM public.users AS pu
|
||||
WHERE au.id = pu.id;
|
||||
|
||||
-- 2.8 For users without a public.users row, migrate auth.users.name to profile
|
||||
UPDATE auth.users
|
||||
SET profile = jsonb_build_object('name', name)
|
||||
WHERE name IS NOT NULL
|
||||
AND (profile IS NULL OR profile = '{}'::jsonb)
|
||||
AND id NOT IN (SELECT id FROM public.users);
|
||||
|
||||
-- 2.9 Update all foreign key constraints that reference public.users to use auth.users instead
|
||||
-- This handles any custom tables developers may have created that reference public.users
|
||||
DO $$
|
||||
DECLARE
|
||||
fk_record RECORD;
|
||||
drop_sql TEXT;
|
||||
create_sql TEXT;
|
||||
BEGIN
|
||||
-- Find all foreign keys that reference public.users
|
||||
FOR fk_record IN
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
rc.delete_rule,
|
||||
rc.update_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_name = rc.constraint_name
|
||||
AND tc.table_schema = rc.constraint_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON rc.unique_constraint_name = ccu.constraint_name
|
||||
AND rc.unique_constraint_schema = ccu.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_schema = 'public'
|
||||
AND ccu.table_name = 'users'
|
||||
LOOP
|
||||
-- Drop the old foreign key
|
||||
drop_sql := format(
|
||||
'ALTER TABLE %I.%I DROP CONSTRAINT IF EXISTS %I',
|
||||
fk_record.table_schema,
|
||||
fk_record.table_name,
|
||||
fk_record.constraint_name
|
||||
);
|
||||
EXECUTE drop_sql;
|
||||
|
||||
-- Recreate with reference to auth.users
|
||||
create_sql := format(
|
||||
'ALTER TABLE %I.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES auth.users(id) ON DELETE %s ON UPDATE %s',
|
||||
fk_record.table_schema,
|
||||
fk_record.table_name,
|
||||
fk_record.constraint_name,
|
||||
fk_record.column_name,
|
||||
fk_record.delete_rule,
|
||||
fk_record.update_rule
|
||||
);
|
||||
EXECUTE create_sql;
|
||||
|
||||
RAISE NOTICE 'Updated FK constraint % on %.% to reference auth.users',
|
||||
fk_record.constraint_name, fk_record.table_schema, fk_record.table_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- 2.10 Drop public.users table (profile data now stored in auth.users.profile)
|
||||
-- First drop RLS policies
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON public.users;
|
||||
DROP POLICY IF EXISTS "Disable delete for users" ON public.users;
|
||||
DROP POLICY IF EXISTS "Enable update for users based on user_id" ON public.users;
|
||||
-- Drop the table (CASCADE will handle any remaining dependencies)
|
||||
DROP TABLE IF EXISTS public.users CASCADE;
|
||||
|
||||
-- 2.11 Drop the name column from auth.users (data already migrated to profile)
|
||||
ALTER TABLE auth.users DROP COLUMN IF EXISTS name;
|
||||
|
||||
-- Note: _storage.uploaded_by FK is handled in Part 4 when moving to storage schema
|
||||
|
||||
-- 2.12 Add is_project_admin and is_anonymous columns to auth.users
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_project_admin BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- 2.13 Move _auth_configs table to auth schema and rename to 'configs'
|
||||
ALTER TABLE public._auth_configs SET SCHEMA auth;
|
||||
ALTER TABLE auth._auth_configs RENAME TO configs;
|
||||
|
||||
-- 2.14 Move _oauth_configs table to auth schema and rename to 'oauth_configs'
|
||||
ALTER TABLE public._oauth_configs SET SCHEMA auth;
|
||||
ALTER TABLE auth._oauth_configs RENAME TO oauth_configs;
|
||||
|
||||
-- 2.15 Recreate FK: auth.oauth_configs.secret_id -> system.secrets(id)
|
||||
ALTER TABLE auth.oauth_configs
|
||||
ADD CONSTRAINT oauth_configs_secret_id_fkey
|
||||
FOREIGN KEY (secret_id) REFERENCES system.secrets(id) ON DELETE RESTRICT;
|
||||
|
||||
-- 2.16 Move _email_otps table to auth schema and rename to 'email_otps'
|
||||
ALTER TABLE public._email_otps SET SCHEMA auth;
|
||||
ALTER TABLE auth._email_otps RENAME TO email_otps;
|
||||
|
||||
-- Note: auth schema is internal and should NOT be exposed to PUBLIC.
|
||||
-- However, we grant limited access to auth.users for public profile info.
|
||||
|
||||
-- 2.17 Grant limited public access to auth.users (only safe columns)
|
||||
GRANT USAGE ON SCHEMA auth TO PUBLIC;
|
||||
|
||||
-- Grant SELECT on specific columns only (public profile info)
|
||||
GRANT SELECT (id, profile, created_at) ON auth.users TO PUBLIC;
|
||||
|
||||
-- Grant UPDATE on profile column only (users can update their own profile)
|
||||
GRANT UPDATE (profile) ON auth.users TO PUBLIC;
|
||||
|
||||
-- 2.18 Enable RLS on auth.users for row-level access control
|
||||
-- Note: auth.uid() function already exists from migration 013
|
||||
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: Everyone can SELECT public columns (id, profile, created_at)
|
||||
-- Column-level GRANT above already restricts which columns can be read
|
||||
CREATE POLICY "Public can view user profiles" ON auth.users
|
||||
FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- Policy: Users can only UPDATE their own row
|
||||
CREATE POLICY "Users can update own profile" ON auth.users
|
||||
FOR UPDATE
|
||||
USING (id = auth.uid())
|
||||
WITH CHECK (id = auth.uid());
|
||||
|
||||
-- 2.19 Drop obsolete public schema helper functions (migrated to auth schema)
|
||||
-- NOTE: CASCADE intentionally used to force migration from public.uid() to auth.uid()
|
||||
-- Any dependent objects (policies, views, functions, defaults) will be dropped.
|
||||
-- Users must recreate them using auth.uid(), auth.role(), auth.email() instead.
|
||||
DROP FUNCTION IF EXISTS public.uid() CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.role() CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.email() CASCADE;
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 3: AI SCHEMA (configs, usage)
|
||||
-- ============================================================================
|
||||
|
||||
-- 3.1 Create the ai schema
|
||||
CREATE SCHEMA IF NOT EXISTS ai;
|
||||
|
||||
-- 3.2 Drop FK constraint from _ai_usage to _ai_configs before moving
|
||||
ALTER TABLE public._ai_usage
|
||||
DROP CONSTRAINT IF EXISTS _ai_usage_config_id_fkey;
|
||||
|
||||
-- 3.3 Move _ai_configs table to ai schema and rename to 'configs'
|
||||
ALTER TABLE public._ai_configs SET SCHEMA ai;
|
||||
ALTER TABLE ai._ai_configs RENAME TO configs;
|
||||
|
||||
-- 3.4 Move _ai_usage table to ai schema and rename to 'usage'
|
||||
ALTER TABLE public._ai_usage SET SCHEMA ai;
|
||||
ALTER TABLE ai._ai_usage RENAME TO usage;
|
||||
|
||||
-- 3.5 Recreate FK: ai.usage.config_id -> ai.configs(id)
|
||||
ALTER TABLE ai.usage
|
||||
ADD CONSTRAINT usage_config_id_fkey
|
||||
FOREIGN KEY (config_id) REFERENCES ai.configs(id) ON DELETE NO ACTION;
|
||||
|
||||
-- Note: ai schema is internal and should NOT be exposed to PUBLIC.
|
||||
-- Access is controlled through the application's database connection.
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 4: STORAGE SCHEMA (buckets, objects)
|
||||
-- ============================================================================
|
||||
|
||||
-- 4.1 Create the storage schema
|
||||
CREATE SCHEMA IF NOT EXISTS storage;
|
||||
|
||||
-- 4.2 Drop FK constraints from _storage before moving
|
||||
ALTER TABLE public._storage
|
||||
DROP CONSTRAINT IF EXISTS _storage_bucket_fkey;
|
||||
|
||||
ALTER TABLE public._storage
|
||||
DROP CONSTRAINT IF EXISTS _storage_uploaded_by_fkey;
|
||||
|
||||
-- 4.3 Move _storage_buckets table to storage schema and rename to 'buckets'
|
||||
ALTER TABLE public._storage_buckets SET SCHEMA storage;
|
||||
ALTER TABLE storage._storage_buckets RENAME TO buckets;
|
||||
|
||||
-- 4.4 Move _storage table to storage schema and rename to 'objects'
|
||||
ALTER TABLE public._storage SET SCHEMA storage;
|
||||
ALTER TABLE storage._storage RENAME TO objects;
|
||||
|
||||
-- 4.5 Recreate FK: storage.objects.bucket -> storage.buckets(name)
|
||||
ALTER TABLE storage.objects
|
||||
ADD CONSTRAINT objects_bucket_fkey
|
||||
FOREIGN KEY (bucket) REFERENCES storage.buckets(name) ON DELETE CASCADE;
|
||||
|
||||
-- 4.6 Recreate FK: storage.objects.uploaded_by -> auth.users(id)
|
||||
ALTER TABLE storage.objects
|
||||
ADD CONSTRAINT objects_uploaded_by_fkey
|
||||
FOREIGN KEY (uploaded_by) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Note: storage schema is internal and should NOT be exposed to PUBLIC.
|
||||
-- Access is controlled through the application's database connection.
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 5: FUNCTIONS SCHEMA (definitions)
|
||||
-- ============================================================================
|
||||
|
||||
-- 5.1 Create the functions schema
|
||||
CREATE SCHEMA IF NOT EXISTS functions;
|
||||
|
||||
-- 5.2 Move _functions table to functions schema and rename to 'definitions'
|
||||
ALTER TABLE public._functions SET SCHEMA functions;
|
||||
ALTER TABLE functions._functions RENAME TO definitions;
|
||||
|
||||
-- Note: functions schema is internal and should NOT be exposed to PUBLIC.
|
||||
-- Access is controlled through the application's database connection.
|
||||
|
||||
-- ============================================================================
|
||||
-- PART 6: UTILITY FUNCTIONS CLEANUP
|
||||
-- ============================================================================
|
||||
|
||||
-- 6.1 Create system.update_updated_at() function (replaces public.update_updated_at_column)
|
||||
CREATE OR REPLACE FUNCTION system.update_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 6.2 Update all triggers to use system.update_updated_at() and rename (remove double underscore)
|
||||
|
||||
-- system.secrets: update__secrets_updated_at -> update_secrets_updated_at
|
||||
DROP TRIGGER IF EXISTS update__secrets_updated_at ON system.secrets;
|
||||
CREATE TRIGGER update_secrets_updated_at
|
||||
BEFORE UPDATE ON system.secrets
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- system.audit_logs: update__audit_logs_updated_at -> update_audit_logs_updated_at
|
||||
DROP TRIGGER IF EXISTS update__audit_logs_updated_at ON system.audit_logs;
|
||||
CREATE TRIGGER update_audit_logs_updated_at
|
||||
BEFORE UPDATE ON system.audit_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- auth.configs: update__auth_configs_updated_at -> update_configs_updated_at
|
||||
DROP TRIGGER IF EXISTS update__auth_configs_updated_at ON auth.configs;
|
||||
CREATE TRIGGER update_configs_updated_at
|
||||
BEFORE UPDATE ON auth.configs
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- auth.oauth_configs: update__oauth_configs_updated_at -> update_oauth_configs_updated_at
|
||||
DROP TRIGGER IF EXISTS update__oauth_configs_updated_at ON auth.oauth_configs;
|
||||
CREATE TRIGGER update_oauth_configs_updated_at
|
||||
BEFORE UPDATE ON auth.oauth_configs
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- auth.email_otps: update__email_otps_updated_at -> update_email_otps_updated_at
|
||||
DROP TRIGGER IF EXISTS update__email_otps_updated_at ON auth.email_otps;
|
||||
CREATE TRIGGER update_email_otps_updated_at
|
||||
BEFORE UPDATE ON auth.email_otps
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- functions.definitions: update__edge_functions_updated_at -> update_definitions_updated_at
|
||||
DROP TRIGGER IF EXISTS update__edge_functions_updated_at ON functions.definitions;
|
||||
CREATE TRIGGER update_definitions_updated_at
|
||||
BEFORE UPDATE ON functions.definitions
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- realtime.channels: trg_channels_updated_at -> update_channels_updated_at
|
||||
DROP TRIGGER IF EXISTS trg_channels_updated_at ON realtime.channels;
|
||||
CREATE TRIGGER update_channels_updated_at
|
||||
BEFORE UPDATE ON realtime.channels
|
||||
FOR EACH ROW EXECUTE FUNCTION system.update_updated_at();
|
||||
|
||||
-- 6.3 Move reload_postgrest_schema to system schema
|
||||
-- Recreate in system schema (ALTER FUNCTION SET SCHEMA doesn't work well with search_path)
|
||||
CREATE OR REPLACE FUNCTION system.reload_postgrest_schema()
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
RAISE NOTICE 'PostgREST schema reload notification sent';
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 6.4 Move event trigger functions to system schema
|
||||
-- These functions auto-create project_admin_policy when RLS is enabled on tables
|
||||
|
||||
-- First, drop the event triggers (they reference the old functions)
|
||||
DROP EVENT TRIGGER IF EXISTS create_policies_on_table_create;
|
||||
DROP EVENT TRIGGER IF EXISTS create_policies_on_rls_enable;
|
||||
|
||||
-- Recreate functions in system schema
|
||||
CREATE OR REPLACE FUNCTION system.create_default_policies()
|
||||
RETURNS event_trigger AS $$
|
||||
DECLARE
|
||||
obj record;
|
||||
table_schema text;
|
||||
table_name text;
|
||||
has_rls boolean;
|
||||
BEGIN
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE'
|
||||
LOOP
|
||||
SELECT INTO table_schema, table_name
|
||||
split_part(obj.object_identity, '.', 1),
|
||||
trim(both '"' from split_part(obj.object_identity, '.', 2));
|
||||
SELECT INTO has_rls
|
||||
rowsecurity
|
||||
FROM pg_tables
|
||||
WHERE schemaname = table_schema
|
||||
AND tablename = table_name;
|
||||
IF has_rls THEN
|
||||
EXECUTE format('CREATE POLICY "project_admin_policy" ON %s FOR ALL TO project_admin USING (true) WITH CHECK (true)', obj.object_identity);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION system.create_policies_after_rls()
|
||||
RETURNS event_trigger AS $$
|
||||
DECLARE
|
||||
obj record;
|
||||
table_schema text;
|
||||
table_name text;
|
||||
BEGIN
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'ALTER TABLE'
|
||||
LOOP
|
||||
SELECT INTO table_schema, table_name
|
||||
split_part(obj.object_identity, '.', 1),
|
||||
trim(both '"' from split_part(obj.object_identity, '.', 2));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_tables
|
||||
WHERE schemaname = table_schema
|
||||
AND tablename = table_name
|
||||
AND rowsecurity = true
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = table_schema
|
||||
AND tablename = table_name
|
||||
) THEN
|
||||
EXECUTE format('CREATE POLICY "project_admin_policy" ON %s FOR ALL TO project_admin USING (true) WITH CHECK (true)', obj.object_identity);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Recreate event triggers pointing to system schema functions
|
||||
CREATE EVENT TRIGGER create_policies_on_table_create
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('CREATE TABLE')
|
||||
EXECUTE FUNCTION system.create_default_policies();
|
||||
|
||||
CREATE EVENT TRIGGER create_policies_on_rls_enable
|
||||
ON ddl_command_end
|
||||
WHEN TAG IN ('ALTER TABLE')
|
||||
EXECUTE FUNCTION system.create_policies_after_rls();
|
||||
|
||||
-- 6.5 Drop obsolete functions
|
||||
DROP FUNCTION IF EXISTS public.create_default_policies() CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.create_policies_after_rls() CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.reload_postgrest_schema() CASCADE;
|
||||
DROP FUNCTION IF EXISTS public.update_updated_at_column() CASCADE;
|
||||
DROP FUNCTION IF EXISTS realtime.update_updated_at() CASCADE;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user