chore: import upstream snapshot with attribution
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:40 +08:00
commit 3a28426bf4
1399 changed files with 257375 additions and 0 deletions
+318
View File
@@ -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
)
);
}
}
}
+66
View File
@@ -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');
+160
View File
@@ -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();
}
+111
View File
@@ -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 };
+300
View File
@@ -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);
}
}
);
+170
View File
@@ -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
+594
View File
@@ -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 };
+291
View File
@@ -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;
+166
View File
@@ -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;
}
+20
View File
@@ -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);
}
}
);
+239
View File
@@ -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