Added Documents Feature
This commit is contained in:
23
backend/src/core/README.md
Normal file
23
backend/src/core/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Core Module Index
|
||||
|
||||
## Configuration (`src/core/config/`)
|
||||
- `config-loader.ts` — Load and validate environment variables
|
||||
- `database.ts` — PostgreSQL connection pool
|
||||
- `redis.ts` — Redis client and cache helpers
|
||||
- `tenant.ts` — Tenant configuration utilities
|
||||
|
||||
## Plugins (`src/core/plugins/`)
|
||||
- `auth.plugin.ts` — Auth0 JWT via JWKS (@fastify/jwt, get-jwks)
|
||||
- `error.plugin.ts` — Error handling
|
||||
- `logging.plugin.ts` — Request logging
|
||||
|
||||
## Logging (`src/core/logging/`)
|
||||
- `logger.ts` — Structured logging (Winston)
|
||||
|
||||
## Middleware
|
||||
- `middleware/tenant.ts` — Tenant extraction/validation
|
||||
|
||||
## Storage (`src/core/storage/`)
|
||||
- `storage.service.ts` — Storage abstraction
|
||||
- `adapters/minio.adapter.ts` — MinIO S3-compatible adapter
|
||||
|
||||
295
backend/src/core/config/config-loader.ts
Normal file
295
backend/src/core/config/config-loader.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* K8s-aligned Configuration Loader
|
||||
* Loads configuration from YAML files and secrets from mounted files
|
||||
* Replaces environment variable based configuration for production k8s compatibility
|
||||
*/
|
||||
import * as yaml from 'js-yaml';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
// Configuration schema definition
|
||||
const configSchema = z.object({
|
||||
// Server configuration
|
||||
server: z.object({
|
||||
name: z.string(),
|
||||
port: z.number(),
|
||||
environment: z.string(),
|
||||
tenant_id: z.string(),
|
||||
node_env: z.string(),
|
||||
}),
|
||||
|
||||
// Database configuration
|
||||
database: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
name: z.string(),
|
||||
user: z.string(),
|
||||
pool_size: z.number().optional().default(20),
|
||||
}),
|
||||
|
||||
// Redis configuration
|
||||
redis: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
db: z.number().optional().default(0),
|
||||
}),
|
||||
|
||||
// Auth0 configuration
|
||||
auth0: z.object({
|
||||
domain: z.string(),
|
||||
audience: z.string(),
|
||||
}),
|
||||
|
||||
// Platform services configuration
|
||||
platform: z.object({
|
||||
services: z.object({
|
||||
vehicles: z.object({
|
||||
url: z.string(),
|
||||
timeout: z.string(),
|
||||
}),
|
||||
tenants: z.object({
|
||||
url: z.string(),
|
||||
timeout: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
// MinIO configuration
|
||||
minio: z.object({
|
||||
endpoint: z.string(),
|
||||
port: z.number(),
|
||||
bucket: z.string(),
|
||||
}),
|
||||
|
||||
// External APIs configuration
|
||||
external: z.object({
|
||||
vpic: z.object({
|
||||
url: z.string(),
|
||||
timeout: z.string(),
|
||||
}),
|
||||
}),
|
||||
|
||||
// Service configuration
|
||||
service: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
|
||||
// CORS configuration
|
||||
cors: z.object({
|
||||
origins: z.array(z.string()),
|
||||
allow_credentials: z.boolean(),
|
||||
max_age: z.number(),
|
||||
}),
|
||||
|
||||
// Frontend configuration
|
||||
frontend: z.object({
|
||||
tenant_id: z.string(),
|
||||
api_base_url: z.string(),
|
||||
auth0: z.object({
|
||||
domain: z.string(),
|
||||
audience: z.string(),
|
||||
}),
|
||||
}),
|
||||
|
||||
// Health check configuration
|
||||
health: z.object({
|
||||
endpoints: z.object({
|
||||
basic: z.string(),
|
||||
ready: z.string(),
|
||||
live: z.string(),
|
||||
startup: z.string(),
|
||||
}),
|
||||
probes: z.object({
|
||||
startup: z.object({
|
||||
initial_delay: z.string(),
|
||||
period: z.string(),
|
||||
timeout: z.string(),
|
||||
failure_threshold: z.number(),
|
||||
}),
|
||||
readiness: z.object({
|
||||
period: z.string(),
|
||||
timeout: z.string(),
|
||||
failure_threshold: z.number(),
|
||||
}),
|
||||
liveness: z.object({
|
||||
period: z.string(),
|
||||
timeout: z.string(),
|
||||
failure_threshold: z.number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
// Logging configuration
|
||||
logging: z.object({
|
||||
level: z.string(),
|
||||
format: z.string(),
|
||||
destinations: z.array(z.string()),
|
||||
}),
|
||||
|
||||
// Performance configuration
|
||||
performance: z.object({
|
||||
request_timeout: z.string(),
|
||||
max_request_size: z.string(),
|
||||
compression_enabled: z.boolean(),
|
||||
circuit_breaker: z.object({
|
||||
enabled: z.boolean(),
|
||||
failure_threshold: z.number(),
|
||||
timeout: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Secrets schema definition
|
||||
const secretsSchema = z.object({
|
||||
postgres_password: z.string(),
|
||||
minio_access_key: z.string(),
|
||||
minio_secret_key: z.string(),
|
||||
platform_vehicles_api_key: z.string(),
|
||||
auth0_client_secret: z.string(),
|
||||
google_maps_api_key: z.string(),
|
||||
});
|
||||
|
||||
type Config = z.infer<typeof configSchema>;
|
||||
type Secrets = z.infer<typeof secretsSchema>;
|
||||
|
||||
export interface AppConfiguration {
|
||||
config: Config;
|
||||
secrets: Secrets;
|
||||
|
||||
// Convenience accessors for common patterns
|
||||
getDatabaseUrl(): string;
|
||||
getRedisUrl(): string;
|
||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||
getPlatformServiceConfig(service: 'vehicles' | 'tenants'): { url: string; apiKey: string };
|
||||
getMinioConfig(): { endpoint: string; port: number; accessKey: string; secretKey: string; bucket: string };
|
||||
}
|
||||
|
||||
class ConfigurationLoader {
|
||||
private configPath: string;
|
||||
private secretsDir: string;
|
||||
private cachedConfig: AppConfiguration | null = null;
|
||||
|
||||
constructor() {
|
||||
this.configPath = process.env.CONFIG_PATH || '/app/config/production.yml';
|
||||
this.secretsDir = process.env.SECRETS_DIR || '/run/secrets';
|
||||
}
|
||||
|
||||
private loadYamlConfig(): Config {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
throw new Error(`Configuration file not found at ${this.configPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContents = fs.readFileSync(this.configPath, 'utf8');
|
||||
const yamlData = yaml.load(fileContents) as any;
|
||||
|
||||
return configSchema.parse(yamlData);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load configuration from ${this.configPath}`, { error });
|
||||
throw new Error(`Configuration loading failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private loadSecrets(): Secrets {
|
||||
const secrets: Partial<Secrets> = {};
|
||||
|
||||
const secretFiles = [
|
||||
'postgres-password',
|
||||
'minio-access-key',
|
||||
'minio-secret-key',
|
||||
'platform-vehicles-api-key',
|
||||
'auth0-client-secret',
|
||||
'google-maps-api-key',
|
||||
];
|
||||
|
||||
for (const secretFile of secretFiles) {
|
||||
const secretPath = path.join(this.secretsDir, secretFile);
|
||||
const secretKey = secretFile.replace(/-/g, '_') as keyof Secrets;
|
||||
|
||||
if (fs.existsSync(secretPath)) {
|
||||
try {
|
||||
const secretValue = fs.readFileSync(secretPath, 'utf8').trim();
|
||||
(secrets as any)[secretKey] = secretValue;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read secret file ${secretPath}`, { error });
|
||||
}
|
||||
} else {
|
||||
logger.error(`Secret file not found: ${secretPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return secretsSchema.parse(secrets);
|
||||
} catch (error) {
|
||||
logger.error('Secrets validation failed', { error });
|
||||
throw new Error(`Secrets loading failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public load(): AppConfiguration {
|
||||
if (this.cachedConfig) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
const config = this.loadYamlConfig();
|
||||
const secrets = this.loadSecrets();
|
||||
|
||||
this.cachedConfig = {
|
||||
config,
|
||||
secrets,
|
||||
|
||||
getDatabaseUrl(): string {
|
||||
return `postgresql://${config.database.user}:${secrets.postgres_password}@${config.database.host}:${config.database.port}/${config.database.name}`;
|
||||
},
|
||||
|
||||
getRedisUrl(): string {
|
||||
return `redis://${config.redis.host}:${config.redis.port}/${config.redis.db}`;
|
||||
},
|
||||
|
||||
getAuth0Config() {
|
||||
return {
|
||||
domain: config.auth0.domain,
|
||||
audience: config.auth0.audience,
|
||||
clientSecret: secrets.auth0_client_secret,
|
||||
};
|
||||
},
|
||||
|
||||
getPlatformServiceConfig(service: 'vehicles' | 'tenants') {
|
||||
const serviceConfig = config.platform.services[service];
|
||||
const apiKey = service === 'vehicles' ? secrets.platform_vehicles_api_key : 'mvp-platform-tenants-secret-key';
|
||||
|
||||
return {
|
||||
url: serviceConfig.url,
|
||||
apiKey,
|
||||
};
|
||||
},
|
||||
|
||||
getMinioConfig() {
|
||||
return {
|
||||
endpoint: config.minio.endpoint,
|
||||
port: config.minio.port,
|
||||
accessKey: secrets.minio_access_key,
|
||||
secretKey: secrets.minio_secret_key,
|
||||
bucket: config.minio.bucket,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
logger.info('Configuration loaded successfully', {
|
||||
configSource: 'yaml',
|
||||
secretsSource: 'files',
|
||||
});
|
||||
|
||||
return this.cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const configLoader = new ConfigurationLoader();
|
||||
export const appConfig = configLoader.load();
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { Config, Secrets };
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Environment configuration with validation
|
||||
* @ai-context Validates all env vars at startup, single source of truth
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('production'),
|
||||
PORT: z.string().transform(Number).default('3001'),
|
||||
|
||||
// Database
|
||||
DB_HOST: z.string().default('localhost'),
|
||||
DB_PORT: z.string().transform(Number).default('5432'),
|
||||
DB_NAME: z.string().default('motovaultpro'),
|
||||
DB_USER: z.string().default('postgres'),
|
||||
DB_PASSWORD: z.string().default('password'),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.string().transform(Number).default('6379'),
|
||||
|
||||
// Auth0 - Required for JWT validation
|
||||
AUTH0_DOMAIN: z.string().min(1, 'AUTH0_DOMAIN is required for JWT authentication'),
|
||||
AUTH0_CLIENT_ID: z.string().min(1, 'AUTH0_CLIENT_ID is required'),
|
||||
AUTH0_CLIENT_SECRET: z.string().min(1, 'AUTH0_CLIENT_SECRET is required'),
|
||||
AUTH0_AUDIENCE: z.string().min(1, 'AUTH0_AUDIENCE is required for JWT validation'),
|
||||
|
||||
// External APIs
|
||||
GOOGLE_MAPS_API_KEY: z.string().default('development'),
|
||||
VPIC_API_URL: z.string().default('https://vpic.nhtsa.dot.gov/api/vehicles'),
|
||||
|
||||
// Platform Services
|
||||
PLATFORM_VEHICLES_API_URL: z.string().default('http://mvp-platform-vehicles-api:8000'),
|
||||
PLATFORM_VEHICLES_API_KEY: z.string().default('mvp-platform-vehicles-secret-key'),
|
||||
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||
MINIO_ACCESS_KEY: z.string().default('minioadmin'),
|
||||
MINIO_SECRET_KEY: z.string().default('minioadmin123'),
|
||||
MINIO_BUCKET: z.string().default('motovaultpro'),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof envSchema>;
|
||||
|
||||
// Validate and export - now with defaults for build-time compilation
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
// Environment configuration validated and exported
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { appConfig } from './config-loader';
|
||||
|
||||
// Simple in-memory cache for tenant validation
|
||||
const tenantValidityCache = new Map<string, { ok: boolean; ts: number }>();
|
||||
@@ -17,18 +18,18 @@ export interface TenantConfig {
|
||||
}
|
||||
|
||||
export const getTenantConfig = (): TenantConfig => {
|
||||
const tenantId = process.env.TENANT_ID || 'admin';
|
||||
|
||||
const tenantId = appConfig.config.server.tenant_id;
|
||||
|
||||
const databaseUrl = tenantId === 'admin'
|
||||
? `postgresql://${process.env.DB_USER || 'motovault_user'}:${process.env.DB_PASSWORD}@${process.env.DB_HOST || 'postgres'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'motovault'}`
|
||||
: `postgresql://motovault_user:${process.env.DB_PASSWORD}@${tenantId}-postgres:5432/motovault`;
|
||||
|
||||
? appConfig.getDatabaseUrl()
|
||||
: `postgresql://${appConfig.config.database.user}:${appConfig.secrets.postgres_password}@${tenantId}-postgres:5432/${appConfig.config.database.name}`;
|
||||
|
||||
const redisUrl = tenantId === 'admin'
|
||||
? `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || '6379'}`
|
||||
? appConfig.getRedisUrl()
|
||||
: `redis://${tenantId}-redis:6379`;
|
||||
|
||||
const platformServicesUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
|
||||
|
||||
const platformServicesUrl = appConfig.getPlatformServiceConfig('tenants').url;
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
databaseUrl,
|
||||
@@ -48,7 +49,7 @@ export const isValidTenant = async (tenantId: string): Promise<boolean> => {
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
const baseUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
const baseUrl = appConfig.getPlatformServiceConfig('tenants').url;
|
||||
const url = `${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}`;
|
||||
const resp = await axios.get(url, { timeout: 2000 });
|
||||
ok = resp.status === 200;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import buildGetJwks from 'get-jwks';
|
||||
import { env } from '../config/environment';
|
||||
import { appConfig } from '../config/config-loader';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -19,8 +19,10 @@ declare module 'fastify' {
|
||||
}
|
||||
|
||||
const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
const auth0Config = appConfig.getAuth0Config();
|
||||
|
||||
// Security validation: ensure AUTH0_DOMAIN is properly configured
|
||||
if (!env.AUTH0_DOMAIN || !env.AUTH0_DOMAIN.includes('.auth0.com')) {
|
||||
if (!auth0Config.domain || !auth0Config.domain.includes('.auth0.com')) {
|
||||
throw new Error('AUTH0_DOMAIN must be a valid Auth0 domain');
|
||||
}
|
||||
|
||||
@@ -37,7 +39,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
const { header: { kid, alg }, payload: { iss } } = token;
|
||||
|
||||
// Validate issuer matches Auth0 domain (security: prevent issuer spoofing)
|
||||
const expectedIssuer = `https://${env.AUTH0_DOMAIN}/`;
|
||||
const expectedIssuer = `https://${auth0Config.domain}/`;
|
||||
if (iss !== expectedIssuer) {
|
||||
throw new Error(`Invalid issuer: ${iss}`);
|
||||
}
|
||||
@@ -49,16 +51,16 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
alg
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('JWKS key retrieval failed', {
|
||||
logger.error('JWKS key retrieval failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
domain: env.AUTH0_DOMAIN
|
||||
domain: auth0Config.domain
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
verify: {
|
||||
allowedIss: `https://${env.AUTH0_DOMAIN}/`,
|
||||
allowedAud: env.AUTH0_AUDIENCE,
|
||||
allowedIss: `https://${auth0Config.domain}/`,
|
||||
allowedAud: auth0Config.audience,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,9 +69,9 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
|
||||
logger.info('JWT authentication successful', {
|
||||
logger.info('JWT authentication successful', {
|
||||
userId: request.user?.sub?.substring(0, 8) + '...',
|
||||
audience: env.AUTH0_AUDIENCE
|
||||
audience: auth0Config.audience
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('JWT authentication failed', {
|
||||
|
||||
66
backend/src/core/storage/adapters/minio.adapter.ts
Normal file
66
backend/src/core/storage/adapters/minio.adapter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Client as MinioClient } from 'minio';
|
||||
import type { Readable } from 'stream';
|
||||
import { appConfig } from '../../config/config-loader';
|
||||
import type { HeadObjectResult, SignedUrlOptions, StorageService } from '../storage.service';
|
||||
|
||||
export function createMinioAdapter(): StorageService {
|
||||
const { endpoint, port, accessKey, secretKey } = appConfig.getMinioConfig();
|
||||
|
||||
const client = new MinioClient({
|
||||
endPoint: endpoint,
|
||||
port,
|
||||
useSSL: false,
|
||||
accessKey,
|
||||
secretKey,
|
||||
});
|
||||
|
||||
const normalizeMeta = (contentType?: string, metadata?: Record<string, string>) => {
|
||||
const meta: Record<string, string> = { ...(metadata || {}) };
|
||||
if (contentType) meta['Content-Type'] = contentType;
|
||||
return meta;
|
||||
};
|
||||
|
||||
const adapter: StorageService = {
|
||||
async putObject(bucket, key, body, contentType, metadata) {
|
||||
const meta = normalizeMeta(contentType, metadata);
|
||||
// For Buffer or string, size is known. For Readable, omit size for chunked encoding.
|
||||
if (Buffer.isBuffer(body) || typeof body === 'string') {
|
||||
await client.putObject(bucket, key, body as any, (body as any).length ?? undefined, meta);
|
||||
} else {
|
||||
await client.putObject(bucket, key, body as Readable, undefined, meta);
|
||||
}
|
||||
},
|
||||
|
||||
async getObjectStream(bucket, key) {
|
||||
return client.getObject(bucket, key);
|
||||
},
|
||||
|
||||
async deleteObject(bucket, key) {
|
||||
await client.removeObject(bucket, key);
|
||||
},
|
||||
|
||||
async headObject(bucket, key): Promise<HeadObjectResult> {
|
||||
const stat = await client.statObject(bucket, key);
|
||||
// minio types: size, etag, lastModified, metaData
|
||||
return {
|
||||
size: stat.size,
|
||||
etag: stat.etag,
|
||||
lastModified: stat.lastModified ? new Date(stat.lastModified) : undefined,
|
||||
contentType: (stat.metaData && (stat.metaData['content-type'] || stat.metaData['Content-Type'])) || undefined,
|
||||
metadata: stat.metaData || undefined,
|
||||
};
|
||||
},
|
||||
|
||||
async getSignedUrl(bucket, key, options?: SignedUrlOptions) {
|
||||
const expires = Math.max(1, Math.min(7 * 24 * 3600, options?.expiresSeconds ?? 300));
|
||||
if (options?.method === 'PUT') {
|
||||
// MinIO SDK has presignedPutObject for PUT
|
||||
return client.presignedPutObject(bucket, key, expires);
|
||||
}
|
||||
// Default GET
|
||||
return client.presignedGetObject(bucket, key, expires);
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
49
backend/src/core/storage/storage.service.ts
Normal file
49
backend/src/core/storage/storage.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Provider-agnostic storage facade with S3-compatible surface.
|
||||
* Initial implementation backed by MinIO using the official SDK.
|
||||
*/
|
||||
import type { Readable } from 'stream';
|
||||
import { createMinioAdapter } from './adapters/minio.adapter';
|
||||
|
||||
export type ObjectBody = Buffer | Readable | string;
|
||||
|
||||
export interface SignedUrlOptions {
|
||||
method: 'GET' | 'PUT';
|
||||
expiresSeconds?: number; // default 300s
|
||||
}
|
||||
|
||||
export interface HeadObjectResult {
|
||||
size: number;
|
||||
etag?: string;
|
||||
lastModified?: Date;
|
||||
contentType?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
putObject(
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: ObjectBody,
|
||||
contentType?: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<void>;
|
||||
|
||||
getObjectStream(bucket: string, key: string): Promise<Readable>;
|
||||
|
||||
deleteObject(bucket: string, key: string): Promise<void>;
|
||||
|
||||
headObject(bucket: string, key: string): Promise<HeadObjectResult>;
|
||||
|
||||
getSignedUrl(bucket: string, key: string, options?: SignedUrlOptions): Promise<string>;
|
||||
}
|
||||
|
||||
// Simple factory — currently only MinIO; can add S3 in future without changing feature code
|
||||
let singleton: StorageService | null = null;
|
||||
export function getStorageService(): StorageService {
|
||||
if (!singleton) {
|
||||
singleton = createMinioAdapter();
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user