livrare lot 2
This commit is contained in:
commit
8ecc78e729
763 changed files with 164593 additions and 0 deletions
|
|
@ -0,0 +1,72 @@
|
|||
import { Pool, PoolClient } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
import { requireEnv, optionalEnv } from './env';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = new Pool({
|
||||
host: requireEnv('DB_HOST'),
|
||||
port: parseInt(optionalEnv('DB_PORT', '5000'), 10),
|
||||
database: requireEnv('DB_NAME'),
|
||||
user: requireEnv('DB_USER'),
|
||||
password: requireEnv('DB_PASSWORD'),
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
const SCHEMA = optionalEnv('DB_SCHEMA', 'bos_parammgmt');
|
||||
|
||||
export const query = async <T = any>(sql: string, params?: any[]): Promise<T[]> => {
|
||||
// BEGIN/COMMIT wrap is REQUIRED so SET search_path stays valid for the
|
||||
// following query when pgbouncer is in transaction pool_mode (SET-uri în
|
||||
// afara unei tranzacții se pierd între query-uri, fiindcă pgbouncer dă
|
||||
// server-ul altcuiva după statement). Costul e ~zero (1 round-trip extra
|
||||
// pentru BEGIN+COMMIT, dar amortizat de connection reuse).
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`SET LOCAL search_path TO ${SCHEMA}, public`);
|
||||
const result = await client.query(sql, params);
|
||||
await client.query('COMMIT');
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const queryOne = async <T = any>(sql: string, params?: any[]): Promise<T | null> => {
|
||||
const rows = await query<T>(sql, params);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
export const transaction = async <T>(callback: (client: PoolClient) => Promise<T>): Promise<T> => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// SET LOCAL stays scoped to this transaction — pgbouncer-friendly.
|
||||
await client.query(`SET LOCAL search_path TO ${SCHEMA}, public`);
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const checkHealth = async (): Promise<boolean> => {
|
||||
try {
|
||||
await query('SELECT 1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default pool;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* SMTP/email helper backed by nodemailer.
|
||||
*
|
||||
* ENV:
|
||||
* SMTP_HOST, SMTP_PORT, SMTP_SECURE (true=TLS, false=STARTTLS),
|
||||
* SMTP_USER, SMTP_PASS,
|
||||
* SMTP_FROM_NAME, SMTP_FROM_EMAIL
|
||||
*/
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import { log } from './logger';
|
||||
|
||||
let _transport: Transporter | null = null;
|
||||
|
||||
export interface SendArgs {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export function isEmailEnabled(): boolean {
|
||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
|
||||
}
|
||||
|
||||
export function getTransport(): Transporter {
|
||||
if (_transport) return _transport;
|
||||
if (!isEmailEnabled()) {
|
||||
throw new Error('SMTP is not configured (missing SMTP_HOST/USER/PASS)');
|
||||
}
|
||||
_transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST!,
|
||||
port: parseInt(process.env.SMTP_PORT || '465', 10),
|
||||
secure: (process.env.SMTP_SECURE ?? 'true') === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER!,
|
||||
pass: process.env.SMTP_PASS!,
|
||||
},
|
||||
});
|
||||
log.info(`[email] SMTP transport initialized → ${process.env.SMTP_HOST}:${process.env.SMTP_PORT}`);
|
||||
return _transport;
|
||||
}
|
||||
|
||||
export async function sendEmail(args: SendArgs): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||
if (!isEmailEnabled()) {
|
||||
log.warn(`[email] skipping send to ${args.to} — SMTP not configured`);
|
||||
return { ok: false, error: 'SMTP not configured' };
|
||||
}
|
||||
try {
|
||||
const fromName = process.env.SMTP_FROM_NAME || 'DIDI';
|
||||
const fromEmail = process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER!;
|
||||
const info = await getTransport().sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: args.to,
|
||||
cc: args.cc,
|
||||
bcc: args.bcc,
|
||||
replyTo: args.replyTo,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html,
|
||||
});
|
||||
log.info(`[email] sent → ${args.to} | subject="${args.subject}" | id=${info.messageId}`);
|
||||
return { ok: true, messageId: info.messageId };
|
||||
} catch (err: any) {
|
||||
log.error(`[email] FAILED → ${args.to} | subject="${args.subject}" | error=${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyEmailConnection(): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await getTransport().verify();
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Read a required env var. Throws on startup if missing — never use a default
|
||||
* for credentials, hostnames, or anything that should be explicitly configured.
|
||||
*/
|
||||
export function requireEnv(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an optional env var with a non-secret default (e.g. log level, port).
|
||||
* NEVER pass a credential or internal hostname as fallback.
|
||||
*/
|
||||
export function optionalEnv(name: string, fallback: string): string {
|
||||
return process.env[name] || fallback;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Sends a 500 response without leaking the original error message to the client.
|
||||
* The full error (including stack) is logged with a correlation_id so support
|
||||
* can trace it back from a user report.
|
||||
*
|
||||
* Mirror of agent-v3/src/shared/helpers/error-response.ts.
|
||||
*/
|
||||
import type { Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { log } from './logger';
|
||||
|
||||
export function internalError(res: Response, err: unknown, context?: string): void {
|
||||
const correlationId = randomUUID();
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
log.error(
|
||||
{ correlation_id: correlationId, context: context || null, err: error },
|
||||
'internal error',
|
||||
);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
correlation_id: correlationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* JWT signature gate — RS256 via Keycloak JWKS.
|
||||
*
|
||||
* Global middleware mounted in server.ts BEFORE all routes: any Bearer token
|
||||
* that looks like a JWT must verify (signature + exp) against the issuing
|
||||
* realm's JWKS or the request gets 401. Downstream decode-only helpers
|
||||
* (extractJWTPayload etc.) stay unchanged — by the time they run, the token
|
||||
* is cryptographically trusted.
|
||||
*
|
||||
* Requests without a Bearer JWT pass through untouched; per-route auth
|
||||
* (requireAdmin, extractJWTPayload null-checks) keeps deciding access.
|
||||
*
|
||||
* Env:
|
||||
* KEYCLOAK_URL base incl. relative path, e.g. http://didi-keycloak:8080/auth
|
||||
* JWT_ALLOWED_REALMS comma list (default didi-clients,didi-admins)
|
||||
* JWT_VERIFY_ENABLED 'false' → gate disabled (dev escape hatch, loud warning)
|
||||
*/
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createRemoteJWKSet, jwtVerify, decodeJwt } from 'jose';
|
||||
import { optionalEnv } from './env';
|
||||
import { log } from './logger';
|
||||
|
||||
const ALLOWED_REALMS = (process.env.JWT_ALLOWED_REALMS || 'didi-clients,didi-admins')
|
||||
.split(',')
|
||||
.map(r => r.trim())
|
||||
.filter(Boolean);
|
||||
const VERIFY_ENABLED = process.env.JWT_VERIFY_ENABLED !== 'false';
|
||||
|
||||
const jwksByRealm = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||
|
||||
function getJwks(realm: string): ReturnType<typeof createRemoteJWKSet> {
|
||||
let jwks = jwksByRealm.get(realm);
|
||||
if (!jwks) {
|
||||
const base = optionalEnv('KEYCLOAK_URL', 'http://didi-keycloak:8080/auth').replace(/\/+$/, '');
|
||||
jwks = createRemoteJWKSet(new URL(`${base}/realms/${realm}/protocol/openid-connect/certs`));
|
||||
jwksByRealm.set(realm, jwks);
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function realmFromIssuer(iss: unknown): string | null {
|
||||
if (typeof iss !== 'string') return null;
|
||||
const match = iss.match(/\/realms\/([^/]+)\/?$/);
|
||||
if (!match) return null;
|
||||
return ALLOWED_REALMS.includes(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
export function jwtVerifyGate() {
|
||||
if (!VERIFY_ENABLED) {
|
||||
log.warn('[auth] JWT_VERIFY_ENABLED=false — signature verification DISABLED. Never use in production.');
|
||||
}
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!VERIFY_ENABLED) return next();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) return next();
|
||||
const token = authHeader.slice(7);
|
||||
if (token.split('.').length !== 3) return next(); // opaque API keys pass through
|
||||
|
||||
try {
|
||||
const realm = realmFromIssuer(decodeJwt(token).iss);
|
||||
if (!realm) throw new Error('token issuer not in allowed realms');
|
||||
await jwtVerify(token, getJwks(realm), { algorithms: ['RS256'] });
|
||||
return next();
|
||||
} catch (err) {
|
||||
log.warn(`[auth] JWT rejected on ${req.method} ${req.path}: ${(err as Error).message}`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
error_code: 'JWT_INVALID',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Keycloak admin client — single source of truth for getting an admin access
|
||||
* token via the master realm. Replaces the duplicated helpers in admin.ts and
|
||||
* auth.ts which used different env var names (KEYCLOAK_ADMIN vs
|
||||
* KEYCLOAK_ADMIN_USER) and diverged on error handling.
|
||||
*
|
||||
* Throws on startup if required env vars are missing. Throws on token failure
|
||||
* (no silent fallback).
|
||||
*/
|
||||
import { requireEnv } from './env';
|
||||
|
||||
export async function getKeycloakAdminToken(): Promise<string> {
|
||||
const keycloakUrl = requireEnv('KEYCLOAK_URL');
|
||||
const username = requireEnv('KEYCLOAK_ADMIN');
|
||||
const password = requireEnv('KEYCLOAK_ADMIN_PASSWORD');
|
||||
|
||||
const response = await fetch(`${keycloakUrl}/realms/master/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
username,
|
||||
password,
|
||||
grant_type: 'password',
|
||||
client_id: 'admin-cli',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Keycloak admin token request failed: ${response.status} ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { access_token?: string };
|
||||
if (!data.access_token) {
|
||||
throw new Error('Keycloak admin token response missing access_token');
|
||||
}
|
||||
return data.access_token;
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Structured logger (pino) — single source of truth for all logging.
|
||||
* See agent-v3/src/shared/logger.ts for full docs.
|
||||
*/
|
||||
import pino, { Logger as PinoLogger } from 'pino';
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const level = process.env.LOG_LEVEL || (isProd ? 'info' : 'debug');
|
||||
|
||||
const baseConfig: pino.LoggerOptions = {
|
||||
level,
|
||||
base: {
|
||||
service: 'didi-framework',
|
||||
pid: process.pid,
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
serializers: {
|
||||
err: pino.stdSerializers.err,
|
||||
error: pino.stdSerializers.err,
|
||||
},
|
||||
redact: {
|
||||
paths: [
|
||||
'password', '*.password',
|
||||
'token', '*.token',
|
||||
'authorization', '*.authorization',
|
||||
'apiKey', '*.apiKey', '*.api_key',
|
||||
'cookie', '*.cookie',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
};
|
||||
|
||||
const basePino: PinoLogger = isProd
|
||||
? pino(baseConfig)
|
||||
: pino({
|
||||
...baseConfig,
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss.l',
|
||||
ignore: 'pid,hostname,service',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export interface Logger {
|
||||
trace: (...args: unknown[]) => void;
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
fatal: (...args: unknown[]) => void;
|
||||
child: (bindings: Record<string, unknown>) => Logger;
|
||||
}
|
||||
|
||||
function formatArg(a: unknown): string {
|
||||
if (a == null) return String(a);
|
||||
if (typeof a === 'string') return a;
|
||||
if (a instanceof Error) return a.message;
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}
|
||||
|
||||
function adapt(p: PinoLogger): Logger {
|
||||
const wrap = (lvl: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal') =>
|
||||
(...args: unknown[]): void => {
|
||||
if (args.length === 0) return;
|
||||
const first = args[0];
|
||||
if (
|
||||
first !== null &&
|
||||
typeof first === 'object' &&
|
||||
!Array.isArray(first) &&
|
||||
!(first instanceof Error)
|
||||
) {
|
||||
const msg = args.slice(1).map(formatArg).join(' ');
|
||||
p[lvl](first as Record<string, unknown>, msg || undefined);
|
||||
return;
|
||||
}
|
||||
p[lvl](args.map(formatArg).join(' '));
|
||||
};
|
||||
return {
|
||||
trace: wrap('trace'),
|
||||
debug: wrap('debug'),
|
||||
info: wrap('info'),
|
||||
warn: wrap('warn'),
|
||||
error: wrap('error'),
|
||||
fatal: wrap('fatal'),
|
||||
child: (bindings) => adapt(p.child(bindings)),
|
||||
};
|
||||
}
|
||||
|
||||
export const log: Logger = adapt(basePino);
|
||||
|
||||
export function createChildLogger(bindings: Record<string, unknown>): Logger {
|
||||
return log.child(bindings);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Prometheus metrics + OTel instrumentation
|
||||
*
|
||||
* Exposes /metrics in Prometheus exposition format.
|
||||
* Auto-collects default Node.js metrics (CPU, memory, GC, event loop).
|
||||
* Adds HTTP request metrics with labels (method, route, status_code).
|
||||
*/
|
||||
|
||||
import client from 'prom-client';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const register = new client.Registry();
|
||||
register.setDefaultLabels({ service: 'didi-framework' });
|
||||
client.collectDefaultMetrics({ register });
|
||||
|
||||
// Metric names + label set aligned with agent-v3 (didi_http_requests_total,
|
||||
// label `status`) so the HighErrorRate alert and Grafana panels match both
|
||||
// services with one series name.
|
||||
export const httpRequests = new client.Counter({
|
||||
name: 'didi_http_requests_total',
|
||||
help: 'Total HTTP requests',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const httpDuration = new client.Histogram({
|
||||
name: 'didi_http_request_duration_seconds',
|
||||
help: 'HTTP request duration (seconds)',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const start = process.hrtime.bigint();
|
||||
res.on('finish', () => {
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1e9;
|
||||
const route = (req.route as { path?: string } | undefined)?.path || req.path.replace(/\/[0-9a-f-]{8,}/g, '/:id');
|
||||
const labels = {
|
||||
method: req.method,
|
||||
route,
|
||||
status: res.statusCode.toString(),
|
||||
};
|
||||
httpRequests.inc(labels);
|
||||
httpDuration.observe(labels, duration);
|
||||
});
|
||||
next();
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
/**
|
||||
* MinIO Configuration — Single-bucket architecture
|
||||
*
|
||||
* Migrated 2026-04-25 from multi-bucket (one bucket per user) to single-bucket
|
||||
* (one bucket `didi-prod` shared by everyone, with prefix-based isolation).
|
||||
*
|
||||
* Required by the external MinIO cluster: credentials only allow operations on
|
||||
* the existing `didi-prod` bucket — no `s3:CreateBucket` permission.
|
||||
*
|
||||
* Storage layout:
|
||||
*
|
||||
* didi-prod/
|
||||
* uploads/ ← legacy fallback (was bucket `uploads`)
|
||||
* image-files/ ← legacy (was bucket `image-files`)
|
||||
* audio-files/ ← legacy (was bucket `audio-files`)
|
||||
* video-files/ ← legacy (was bucket `video-files`)
|
||||
* text-files/ ← legacy (was bucket `text-files`)
|
||||
* document-files/ ← legacy (was bucket `document-files`)
|
||||
* pipeline-artifacts/ ← legacy (was bucket `pipeline-artifacts`)
|
||||
* users/{userId}/ ← per-user namespace (was bucket `user-{userId}`)
|
||||
* images/
|
||||
* videos/
|
||||
* videos/frames/
|
||||
* audio-files/
|
||||
* text-files/
|
||||
*
|
||||
* Backward compat: callers passing legacy bucket names (`user-3`, `image-files`)
|
||||
* are auto-translated to `BUCKET` + prefixed key. See `resolveBucketRequest()`.
|
||||
*/
|
||||
|
||||
import * as Minio from 'minio';
|
||||
import { log } from './logger';
|
||||
|
||||
// ============================================================================
|
||||
// CONFIG
|
||||
// ============================================================================
|
||||
|
||||
export const minioConfig = {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '27000', 10),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minio123',
|
||||
// forcePathStyle is implicit in node minio SDK — it always uses path-style
|
||||
};
|
||||
|
||||
/** The single bucket holding all DIDI data. Override via MINIO_BUCKET env. */
|
||||
export const BUCKET = process.env.MINIO_BUCKET || 'didi-prod';
|
||||
|
||||
/** Public URL base for presigned/direct URLs (visible to clients outside Docker). */
|
||||
export const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || `http://localhost:27000`;
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT TYPE → FOLDER MAPPING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Top-level prefixes inside `BUCKET`. Names kept identical to legacy bucket
|
||||
* names so existing object keys remain reachable after migration.
|
||||
*/
|
||||
export const BUCKETS = {
|
||||
UPLOADS: 'uploads',
|
||||
IMAGES: 'image-files',
|
||||
AUDIO: 'audio-files',
|
||||
VIDEO: 'video-files',
|
||||
TEXT: 'text-files',
|
||||
DOCUMENTS: 'document-files',
|
||||
ARTIFACTS: 'pipeline-artifacts',
|
||||
} as const;
|
||||
|
||||
/** Folder names used inside per-user namespace `users/{id}/`. */
|
||||
export const USER_BUCKET_FOLDERS = {
|
||||
IMAGES: 'images',
|
||||
VIDEOS: 'videos',
|
||||
AUDIO: 'audio-files',
|
||||
TEXT: 'text-files',
|
||||
FRAMES: 'videos/frames', // for extracted video frames sent to LLM
|
||||
} as const;
|
||||
|
||||
/** All legacy top-level prefixes (used for backward-compat URL resolution). */
|
||||
const LEGACY_SYSTEM_BUCKETS = new Set<string>(Object.values(BUCKETS));
|
||||
|
||||
export const MIME_TO_BUCKET: Record<string, string> = {
|
||||
// Images
|
||||
'image/jpeg': BUCKETS.IMAGES,
|
||||
'image/png': BUCKETS.IMAGES,
|
||||
'image/gif': BUCKETS.IMAGES,
|
||||
'image/webp': BUCKETS.IMAGES,
|
||||
'image/bmp': BUCKETS.IMAGES,
|
||||
'image/svg+xml': BUCKETS.IMAGES,
|
||||
// Audio
|
||||
'audio/mpeg': BUCKETS.AUDIO,
|
||||
'audio/wav': BUCKETS.AUDIO,
|
||||
'audio/ogg': BUCKETS.AUDIO,
|
||||
'audio/webm': BUCKETS.AUDIO,
|
||||
'audio/flac': BUCKETS.AUDIO,
|
||||
'audio/mp4': BUCKETS.AUDIO,
|
||||
'audio/x-m4a': BUCKETS.AUDIO,
|
||||
// Video
|
||||
'video/mp4': BUCKETS.VIDEO,
|
||||
'video/webm': BUCKETS.VIDEO,
|
||||
'video/quicktime': BUCKETS.VIDEO,
|
||||
'video/x-msvideo': BUCKETS.VIDEO,
|
||||
'video/x-matroska': BUCKETS.VIDEO,
|
||||
// Text
|
||||
'text/plain': BUCKETS.TEXT,
|
||||
'text/html': BUCKETS.TEXT,
|
||||
'text/markdown': BUCKETS.TEXT,
|
||||
'text/csv': BUCKETS.TEXT,
|
||||
// Documents
|
||||
'application/pdf': BUCKETS.DOCUMENTS,
|
||||
'application/msword': BUCKETS.DOCUMENTS,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': BUCKETS.DOCUMENTS,
|
||||
'application/vnd.ms-excel': BUCKETS.DOCUMENTS,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': BUCKETS.DOCUMENTS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the system-level folder prefix for a MIME type.
|
||||
* Used for non-user-scoped uploads (legacy or anonymous).
|
||||
*/
|
||||
export function getBucketForMime(mimeType: string): string {
|
||||
return MIME_TO_BUCKET[mimeType] || BUCKETS.UPLOADS;
|
||||
}
|
||||
|
||||
export const ALLOWED_MIME_TYPES = new Set(Object.keys(MIME_TO_BUCKET));
|
||||
|
||||
export const MAX_FILE_SIZES: Record<string, number> = {
|
||||
image: 20 * 1024 * 1024,
|
||||
audio: 100 * 1024 * 1024,
|
||||
video: 500 * 1024 * 1024,
|
||||
text: 10 * 1024 * 1024,
|
||||
document: 50 * 1024 * 1024,
|
||||
default: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export function getMaxSizeForMime(mimeType: string): number {
|
||||
if (mimeType.startsWith('image/')) return MAX_FILE_SIZES.image;
|
||||
if (mimeType.startsWith('audio/')) return MAX_FILE_SIZES.audio;
|
||||
if (mimeType.startsWith('video/')) return MAX_FILE_SIZES.video;
|
||||
if (mimeType.startsWith('text/')) return MAX_FILE_SIZES.text;
|
||||
if (mimeType.includes('pdf') || mimeType.includes('document') || mimeType.includes('sheet')) {
|
||||
return MAX_FILE_SIZES.document;
|
||||
}
|
||||
return MAX_FILE_SIZES.default;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PATH RESOLUTION (legacy → new)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map a (bucket, objectKey) pair from any era — legacy multi-bucket or new
|
||||
* single-bucket — to the canonical (BUCKET, fullKey) form used by the SDK.
|
||||
*
|
||||
* resolveBucketRequest('user-3', 'images/abc.jpg')
|
||||
* → { bucket: 'didi-prod', key: 'users/3/images/abc.jpg' }
|
||||
*
|
||||
* resolveBucketRequest('image-files', 'foo.jpg')
|
||||
* → { bucket: 'didi-prod', key: 'image-files/foo.jpg' }
|
||||
*
|
||||
* resolveBucketRequest('didi-prod', 'users/3/images/abc.jpg')
|
||||
* → { bucket: 'didi-prod', key: 'users/3/images/abc.jpg' } (already canonical)
|
||||
*
|
||||
* Used by the proxy endpoint that serves legacy URLs from history without
|
||||
* needing a one-shot DB rewrite.
|
||||
*/
|
||||
export function resolveBucketRequest(
|
||||
bucket: string | undefined | null,
|
||||
objectKey: string,
|
||||
): { bucket: string; key: string } {
|
||||
if (!bucket || bucket === BUCKET) {
|
||||
return { bucket: BUCKET, key: objectKey };
|
||||
}
|
||||
// Legacy per-user bucket → users/{id}/ prefix
|
||||
const userMatch = bucket.match(/^user-(\d+)$/);
|
||||
if (userMatch) {
|
||||
return { bucket: BUCKET, key: `users/${userMatch[1]}/${objectKey}` };
|
||||
}
|
||||
// Legacy system bucket → keep the name as top-level prefix
|
||||
if (LEGACY_SYSTEM_BUCKETS.has(bucket)) {
|
||||
return { bucket: BUCKET, key: `${bucket}/${objectKey}` };
|
||||
}
|
||||
// Unknown bucket — pass through (will likely fail with NoSuchBucket, which
|
||||
// is the correct behavior). Don't silently rewrite.
|
||||
return { bucket, key: objectKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical object key for a user upload.
|
||||
* userObjectKey(3, 'image/jpeg', '1234-photo.jpg')
|
||||
* → 'users/3/images/1234-photo.jpg'
|
||||
*/
|
||||
export function userObjectKey(userId: number | string, mimeType: string, filename: string): string {
|
||||
const folder = getUserBucketFolder(mimeType);
|
||||
return `users/${userId}/${folder}/${filename}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLIENT
|
||||
// ============================================================================
|
||||
|
||||
let minioClient: Minio.Client | null = null;
|
||||
|
||||
export function getMinioClient(): Minio.Client {
|
||||
if (!minioClient) {
|
||||
minioClient = new Minio.Client(minioConfig);
|
||||
log.info(
|
||||
`[MinIO] Client initialized: ${minioConfig.endPoint}:${minioConfig.port} ` +
|
||||
`(ssl=${minioConfig.useSSL}, bucket=${BUCKET})`,
|
||||
);
|
||||
}
|
||||
return minioClient;
|
||||
}
|
||||
|
||||
export async function checkMinioHealth(): Promise<boolean> {
|
||||
try {
|
||||
const client = getMinioClient();
|
||||
// Use bucketExists on our bucket — this works even when listBuckets is denied
|
||||
// (which is the case on multi-tenant clusters with restricted IAM).
|
||||
await client.bucketExists(BUCKET);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('[MinIO] Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op in single-bucket mode (we don't have CreateBucket permission).
|
||||
* Kept for backward compat with callers; logs a warning if asked to create
|
||||
* something other than the canonical bucket.
|
||||
*/
|
||||
export async function ensureBucket(bucketName: string): Promise<void> {
|
||||
if (bucketName === BUCKET) return;
|
||||
// Legacy callers passing system bucket names (`uploads`, `image-files`, etc)
|
||||
// — silently no-op. They'll be served from BUCKET via resolveBucketRequest().
|
||||
if (LEGACY_SYSTEM_BUCKETS.has(bucketName) || /^user-\d+$/.test(bucketName)) {
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
`[MinIO] ensureBucket('${bucketName}') ignored — running in single-bucket mode (BUCKET=${BUCKET}). ` +
|
||||
`If you need a separate bucket, ask the storage cluster administrator for s3:CreateBucket permission.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OBJECT OPERATIONS — accept legacy bucket names via resolveBucketRequest()
|
||||
// ============================================================================
|
||||
|
||||
export async function getPresignedUrl(
|
||||
bucket: string,
|
||||
objectName: string,
|
||||
expirySeconds: number = 3600,
|
||||
): Promise<string> {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
return getMinioClient().presignedGetObject(b, key, expirySeconds);
|
||||
}
|
||||
|
||||
export function getDirectUrl(bucket: string, objectName: string): string {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
return `${MINIO_PUBLIC_URL}/${b}/${key}`;
|
||||
}
|
||||
|
||||
export async function getPresignedPutUrl(
|
||||
bucket: string,
|
||||
objectName: string,
|
||||
expirySeconds: number = 3600,
|
||||
): Promise<string> {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
return getMinioClient().presignedPutObject(b, key, expirySeconds);
|
||||
}
|
||||
|
||||
export async function uploadBuffer(
|
||||
bucket: string,
|
||||
objectName: string,
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
metadata?: Record<string, string>,
|
||||
): Promise<{ etag: string; versionId?: string }> {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
// ensureBucket is a no-op in cluster mode; kept for symmetry with legacy.
|
||||
await ensureBucket(b);
|
||||
const result = await getMinioClient().putObject(b, key, buffer, buffer.length, {
|
||||
'Content-Type': mimeType,
|
||||
...metadata,
|
||||
});
|
||||
return { etag: result.etag, versionId: result.versionId || undefined };
|
||||
}
|
||||
|
||||
export async function deleteObject(bucket: string, objectName: string): Promise<void> {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
await getMinioClient().removeObject(b, key);
|
||||
}
|
||||
|
||||
export async function getObjectInfo(
|
||||
bucket: string,
|
||||
objectName: string,
|
||||
): Promise<Minio.BucketItemStat | null> {
|
||||
const { bucket: b, key } = resolveBucketRequest(bucket, objectName);
|
||||
try {
|
||||
return await getMinioClient().statObject(b, key);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'NotFound') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ObjectInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
lastModified?: Date;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export async function listObjects(
|
||||
bucket: string,
|
||||
prefix?: string,
|
||||
maxKeys?: number,
|
||||
): Promise<ObjectInfo[]> {
|
||||
const { bucket: b, key: keyPrefix } = resolveBucketRequest(bucket, prefix || '');
|
||||
const client = getMinioClient();
|
||||
const objects: ObjectInfo[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = client.listObjects(b, keyPrefix, true);
|
||||
stream.on('data', (obj: any) => {
|
||||
if (!maxKeys || objects.length < maxKeys) {
|
||||
objects.push({
|
||||
name: obj.name || '',
|
||||
size: obj.size || 0,
|
||||
lastModified: obj.lastModified,
|
||||
etag: obj.etag,
|
||||
});
|
||||
}
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(objects));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER NAMESPACE (formerly per-user buckets)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User bucket metadata — preserved for callers, but in single-bucket mode the
|
||||
* authoritative source is now PG (`bos_sysadmin.internet_user.storage_*`),
|
||||
* not bucket tags (which we can't set on a shared bucket).
|
||||
*/
|
||||
export interface UserBucketMetadata {
|
||||
visitorId: number;
|
||||
visitorEmail: string;
|
||||
subscriptionPlanId: number;
|
||||
subscriptionPlanName: string;
|
||||
storageLimitGb: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a user namespace. In single-bucket mode this is a logical
|
||||
* operation — S3 has no concept of empty folders, so the namespace
|
||||
* `users/{id}/` only "exists" once it has objects in it.
|
||||
*
|
||||
* Returns shape compatible with old callers (so `auth.ts` doesn't need
|
||||
* changes). `bucketName` is now the canonical BUCKET, and `created` reports
|
||||
* whether this user already had any objects.
|
||||
*
|
||||
* Quota is tracked in PG, not as bucket tags. The planId/planName/storageLimitGb
|
||||
* args are accepted but ignored here — the caller writes them to
|
||||
* `internet_user.storage_limit_bytes` directly.
|
||||
*/
|
||||
export async function createUserBucket(
|
||||
internetUserId: number,
|
||||
email: string,
|
||||
_planId: number,
|
||||
_planName: string,
|
||||
_storageLimitGb: number,
|
||||
): Promise<{ bucketName: string; created: boolean }> {
|
||||
const prefix = `users/${internetUserId}/`;
|
||||
try {
|
||||
const client = getMinioClient();
|
||||
// Check if user already has any objects (rough "exists" signal).
|
||||
const stream = client.listObjects(BUCKET, prefix, false);
|
||||
let hasAny = false;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('data', () => { hasAny = true; resolve(); stream.destroy(); });
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve());
|
||||
});
|
||||
if (hasAny) {
|
||||
log.info(`[MinIO] User namespace already has objects: ${BUCKET}/${prefix}`);
|
||||
return { bucketName: BUCKET, created: false };
|
||||
}
|
||||
log.info(`[MinIO] User namespace ready (lazy-init on first upload): ${BUCKET}/${prefix} for ${email}`);
|
||||
return { bucketName: BUCKET, created: true };
|
||||
} catch (error: any) {
|
||||
log.error(`[MinIO] Failed to check user namespace for ${internetUserId}:`, error.message);
|
||||
// Non-fatal — uploads will still work; just log and continue.
|
||||
return { bucketName: BUCKET, created: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserBucketFolder(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return USER_BUCKET_FOLDERS.IMAGES;
|
||||
if (mimeType.startsWith('video/')) return USER_BUCKET_FOLDERS.VIDEOS;
|
||||
if (mimeType.startsWith('audio/')) return USER_BUCKET_FOLDERS.AUDIO;
|
||||
if (mimeType.startsWith('text/')) return USER_BUCKET_FOLDERS.TEXT;
|
||||
if (mimeType.includes('pdf') || mimeType.includes('document')) return USER_BUCKET_FOLDERS.TEXT;
|
||||
return USER_BUCKET_FOLDERS.TEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute storage usage for a user by listing the `users/{id}/` prefix.
|
||||
* For frequently-checked quota, prefer the PG counter (cheap), and only
|
||||
* fall back to this for periodic reconciliation / repair.
|
||||
*/
|
||||
export async function getUserBucketUsage(internetUserId: number): Promise<{
|
||||
bucketName: string;
|
||||
totalBytes: number;
|
||||
totalFiles: number;
|
||||
}> {
|
||||
try {
|
||||
const objects = await listObjects(BUCKET, `users/${internetUserId}/`);
|
||||
return {
|
||||
bucketName: BUCKET,
|
||||
totalBytes: objects.reduce((sum, obj) => sum + obj.size, 0),
|
||||
totalFiles: objects.length,
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`[MinIO] Failed to compute usage for user ${internetUserId}:`, error.message);
|
||||
return { bucketName: BUCKET, totalBytes: 0, totalFiles: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns metadata. In single-bucket mode this is a thin shim — the real
|
||||
* source is PG. Most callers should query `internet_user` directly.
|
||||
*/
|
||||
export async function getUserBucketMetadata(_internetUserId: number): Promise<UserBucketMetadata | null> {
|
||||
// Bucket tags don't apply to user namespaces in single-bucket mode.
|
||||
// Callers should query PG (bos_sysadmin.internet_user) for plan/quota.
|
||||
// Returning null signals "use PG instead".
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op in single-bucket mode. Plan/quota changes are now PG operations
|
||||
* driven by didiFramework subscription routes.
|
||||
*/
|
||||
export async function updateUserBucketMetadata(
|
||||
_internetUserId: number,
|
||||
_planId: number,
|
||||
_planName: string,
|
||||
_storageLimitGb: number,
|
||||
): Promise<boolean> {
|
||||
// No bucket tags to update in single-bucket mode. Caller should write to PG.
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* OpenTelemetry instrumentation — auto-instrumentat pe Express, HTTP, pg, redis, ioredis.
|
||||
* Trimite trace-uri la OTel Collector → Jaeger.
|
||||
*
|
||||
* Activează cu env var OTEL_ENABLED=true (off implicit ca să nu impacteze cold start).
|
||||
*/
|
||||
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
|
||||
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || 'didi-framework';
|
||||
const ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://didi-otel-collector:4318/v1/traces';
|
||||
|
||||
let sdk: NodeSDK | null = null;
|
||||
|
||||
export function startOtel() {
|
||||
if (process.env.OTEL_ENABLED !== 'true') return;
|
||||
if (sdk) return;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.SERVICE_VERSION || '1.0.0',
|
||||
}),
|
||||
traceExporter: new OTLPTraceExporter({ url: ENDPOINT }),
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
sdk.start();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[otel] started, exporting to ${ENDPOINT} as ${SERVICE_NAME}`);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
if (sdk) {
|
||||
sdk.shutdown().finally(() => process.exit(0));
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Redis connection factory — single source of truth for ioredis config.
|
||||
*
|
||||
* Mirrors agent-v3/src/shared/redis/connection.ts. Handles both legacy AUTH
|
||||
* (password only) and ACL (username + password). Tuned for HA clusters with
|
||||
* occasional failover.
|
||||
*
|
||||
* Env vars:
|
||||
* REDIS_HOST (default: didi-cache)
|
||||
* REDIS_PORT (default: 6379)
|
||||
* REDIS_USERNAME (optional — omit for legacy AUTH)
|
||||
* REDIS_PASSWORD (required)
|
||||
* REDIS_DB (default: 0)
|
||||
*/
|
||||
|
||||
import Redis, { RedisOptions } from 'ioredis';
|
||||
|
||||
export interface CreateRedisOptions {
|
||||
overrides?: Partial<RedisOptions>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function createRedisConnection(opts: CreateRedisOptions = {}): Redis {
|
||||
const host = process.env.REDIS_HOST || 'didi-cache';
|
||||
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const username = process.env.REDIS_USERNAME || undefined;
|
||||
const password = process.env.REDIS_PASSWORD || undefined;
|
||||
const db = parseInt(process.env.REDIS_DB || '0', 10);
|
||||
|
||||
const baseOptions: RedisOptions = {
|
||||
host,
|
||||
port,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
db,
|
||||
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
retryStrategy: (times: number) => Math.min(times * 500, 30000),
|
||||
reconnectOnError: (err: Error) => {
|
||||
const msg = err.message || '';
|
||||
return msg.includes('READONLY') || msg.includes('MASTERDOWN');
|
||||
},
|
||||
|
||||
connectionName: opts.label || 'didi-framework',
|
||||
|
||||
...opts.overrides,
|
||||
};
|
||||
|
||||
return new Redis(baseOptions);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Express middleware that attaches a per-request child logger.
|
||||
* See agent-v3/src/shared/request-logger.ts for full docs.
|
||||
*/
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { log, type Logger } from './logger';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
log: Logger;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestLogger() {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const headerId = (req.header('x-request-id') || '').trim();
|
||||
const requestId = headerId || randomUUID();
|
||||
req.requestId = requestId;
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
|
||||
req.log = log.child({
|
||||
request_id: requestId,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
req.log.debug('request received');
|
||||
|
||||
res.on('finish', () => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const statusCode = res.statusCode;
|
||||
const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info';
|
||||
req.log[level]({ status_code: statusCode, duration_ms: durationMs }, 'request finished');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Stripe client + webhook helpers.
|
||||
*
|
||||
* ENV:
|
||||
* STRIPE_SECRET_KEY — sk_test_... / sk_live_...
|
||||
* STRIPE_WEBHOOK_SECRET — whsec_... (from webhook endpoint settings)
|
||||
* STRIPE_API_VERSION — optional pin (default: latest preview)
|
||||
*/
|
||||
import Stripe from 'stripe';
|
||||
import { log } from './logger';
|
||||
|
||||
let _client: Stripe | null = null;
|
||||
|
||||
export function getStripe(): Stripe {
|
||||
if (_client) return _client;
|
||||
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not set — Stripe operations disabled');
|
||||
}
|
||||
|
||||
_client = new Stripe(key, {
|
||||
apiVersion: (process.env.STRIPE_API_VERSION as Stripe.LatestApiVersion) || '2025-09-30.clover',
|
||||
appInfo: { name: 'didi-framework', version: '2.0.0' },
|
||||
});
|
||||
|
||||
log.info('[stripe] client initialized');
|
||||
return _client;
|
||||
}
|
||||
|
||||
export function getWebhookSecret(): string {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET is not set — webhook signature verification disabled');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function isStripeEnabled(): boolean {
|
||||
return !!process.env.STRIPE_SECRET_KEY && !!process.env.STRIPE_WEBHOOK_SECRET;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue