/** * Tiered storage configuration for wisp-hosting-service * * Implements a three-tier caching strategy: * - Hot (Memory): Instant access for frequently used files (index.html, CSS, JS) * - Warm (Disk): Local cache with eviction policy * - Cold (S3/R2): Object storage as source of truth (optional) * * When S3 is not configured, falls back to disk-only mode (warm tier acts as source of truth). * Hosting service is read-only: S3 writes are always skipped. */ import { createHash } from 'node:crypto' import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' import { createLogger } from '@wispplace/observability' import { acquireDiskStorageTier, acquireS3StorageTier, acquireTieredStorage, DiskStorageTier, MemoryStorageTier, S3StorageTier, type StorageMetadata, type StorageTier, TieredStorage, } from '@wispplace/tiered-storage' import { Effect } from 'effect' const logger = createLogger('hosting-storage') const DEFAULT_CACHE_DIR = './cache/sites' const DEFAULT_HOT_CACHE_SIZE = 104857600 // 100MB const DEFAULT_HOT_CACHE_COUNT = 500 const DEFAULT_WARM_CACHE_SIZE = 10737418240 // 10GB const DEFAULT_HOT_CACHE_TTL_SECONDS = 60 const DEFAULT_S3_REGION = 'us-east-1' const DEFAULT_S3_PREFIX = 'sites/' const DEFAULT_PRIVATE_S3_PREFIX = 'private-sites/' const MAX_CACHE_SIZE_BYTES = 1024 ** 4 // 1TiB const MAX_CACHE_ITEMS = 1_000_000 const MAX_HOT_CACHE_TTL_SECONDS = 365 * 24 * 60 * 60 const MAX_STORAGE_PATH_LENGTH = 1024 const MAX_S3_PREFIX_LENGTH = 512 /** Environment values used to resolve hosting storage configuration. */ export type HostingStorageEnvironment = Readonly> /** Validated hosting storage configuration. */ export interface HostingStorageConfiguration { cacheDir: string hotCacheSize: number hotCacheCount: number warmCacheSize: number warmEvictionPolicy: 'lru' | 'fifo' | 'size' hotCacheTtlSeconds: number s3Bucket: string s3Region: string s3Endpoint: string | undefined s3ForcePathStyle: boolean s3Prefix: string privateS3Bucket: string privateS3Prefix: string awsAccessKeyId: string | undefined awsSecretAccessKey: string | undefined allowDiskSource: boolean } function parsePositiveStorageInteger(value: string | undefined, fallback: number, maximum: number): number { if (!value) return fallback const parsed = Number(value) return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= maximum ? parsed : fallback } function hasExplicitLocalDevelopmentMode(env: HostingStorageEnvironment): boolean { return env.NODE_ENV === 'development' || env.NODE_ENV === 'test' } function validateCacheDirectory(value: string | undefined): string { const cacheDir = value?.trim() || DEFAULT_CACHE_DIR const resolvedCacheDir = resolve(cacheDir) const resolvedRoot = resolve('/') const resolvedCurrentDirectory = resolve(process.cwd()) if ( cacheDir.length > MAX_STORAGE_PATH_LENGTH || cacheDir.includes('\0') || resolvedCacheDir === resolvedRoot || resolvedCacheDir === resolvedCurrentDirectory ) { throw new Error('Invalid CACHE_DIR') } // Follow an existing symlink before startup. This rejects accidental cache roots // such as a `cache` symlink to `/` or to the process working directory. if (existsSync(resolvedCacheDir)) { try { const realCacheDir = realpathSync(resolvedCacheDir) if (realCacheDir === resolvedRoot || realCacheDir === resolvedCurrentDirectory) { throw new Error('Invalid CACHE_DIR') } } catch (error) { if (error instanceof Error && error.message === 'Invalid CACHE_DIR') throw error throw new Error('Invalid CACHE_DIR') } } return cacheDir } function hasControlCharacter(value: string): boolean { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code <= 31 || code === 127) return true } return false } function validateS3Prefix(value: string | undefined, fallback: string, field: string): string { const rawPrefix = value === undefined || value === '' ? fallback : value const prefix = rawPrefix.endsWith('/') ? rawPrefix : `${rawPrefix}/` const segments = prefix.slice(0, -1).split('/') if ( prefix.length > MAX_S3_PREFIX_LENGTH || prefix.startsWith('/') || prefix.includes('\\') || hasControlCharacter(prefix) || segments.some((segment) => !segment || segment === '.' || segment === '..') || !/^[A-Za-z0-9._/-]+$/.test(prefix) ) { throw new Error(`Invalid ${field}`) } return prefix } function isLocalDevelopmentHost(hostname: string): boolean { return ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname.endsWith('.localhost') ) } function validateS3Endpoint(value: string | undefined, allowLocalDevelopment: boolean): string | undefined { if (!value) return undefined let endpoint: URL try { endpoint = new URL(value) } catch { throw new Error('Invalid S3_ENDPOINT') } const allowsLocalHttp = allowLocalDevelopment && endpoint.protocol === 'http:' && isLocalDevelopmentHost(endpoint.hostname) if ( (endpoint.protocol !== 'https:' && !allowsLocalHttp) || !endpoint.hostname || endpoint.username || endpoint.password || endpoint.search || endpoint.hash ) { throw new Error('Invalid S3_ENDPOINT') } return value } function hasValidRedisUrl(value: string | undefined): boolean { if (!value) return false try { const url = new URL(value) return ( (url.protocol === 'redis:' || url.protocol === 'rediss:') && Boolean(url.hostname) && !url.search && !url.hash ) } catch { return false } } /** * Resolve and validate storage configuration before the service starts accepting requests. * * S3 plus Redis are required unless the operator explicitly opts into * single-node disk-source mode. Development and test are the only implicit * local modes; an unknown environment is treated as production-like. * * @param env Environment values to validate * @returns Validated storage configuration * @throws Error when source-of-truth or path configuration is unsafe */ export function resolveHostingStorageConfig(env: HostingStorageEnvironment = process.env): HostingStorageConfiguration { const localDevelopmentMode = hasExplicitLocalDevelopmentMode(env) const diskSourceOptIn = env.HOSTING_ALLOW_DISK_SOURCE === 'true' const s3Bucket = env.S3_BUCKET?.trim() || '' const privateS3Bucket = env.PRIVATE_S3_BUCKET?.trim() || s3Bucket if (diskSourceOptIn && (s3Bucket || env.PRIVATE_S3_BUCKET?.trim())) { throw new Error('HOSTING_ALLOW_DISK_SOURCE cannot be used with S3_BUCKET') } const allowDiskSource = localDevelopmentMode || diskSourceOptIn const s3Endpoint = validateS3Endpoint(env.S3_ENDPOINT?.trim(), localDevelopmentMode) const s3Prefix = validateS3Prefix(env.S3_PREFIX, DEFAULT_S3_PREFIX, 'S3_PREFIX') const privateS3Prefix = validateS3Prefix(env.PRIVATE_S3_PREFIX, DEFAULT_PRIVATE_S3_PREFIX, 'PRIVATE_S3_PREFIX') const warmEvictionPolicy = env.WARM_EVICTION_POLICY?.trim() || 'lru' if (warmEvictionPolicy !== 'lru' && warmEvictionPolicy !== 'fifo' && warmEvictionPolicy !== 'size') { throw new Error('Invalid WARM_EVICTION_POLICY') } if (!allowDiskSource) { if (!s3Bucket) throw new Error('S3_BUCKET is required unless disk-source mode is explicitly enabled') if (!hasValidRedisUrl(env.REDIS_URL)) throw new Error('REDIS_URL is required unless disk-source mode is explicitly enabled') } return { cacheDir: validateCacheDirectory(env.CACHE_DIR), hotCacheSize: parsePositiveStorageInteger(env.HOT_CACHE_SIZE, DEFAULT_HOT_CACHE_SIZE, MAX_CACHE_SIZE_BYTES), hotCacheCount: parsePositiveStorageInteger(env.HOT_CACHE_COUNT, DEFAULT_HOT_CACHE_COUNT, MAX_CACHE_ITEMS), warmCacheSize: parsePositiveStorageInteger(env.WARM_CACHE_SIZE, DEFAULT_WARM_CACHE_SIZE, MAX_CACHE_SIZE_BYTES), warmEvictionPolicy, hotCacheTtlSeconds: parsePositiveStorageInteger( env.HOT_CACHE_TTL, DEFAULT_HOT_CACHE_TTL_SECONDS, MAX_HOT_CACHE_TTL_SECONDS, ), s3Bucket, s3Region: env.S3_REGION?.trim() || DEFAULT_S3_REGION, s3Endpoint, s3ForcePathStyle: env.S3_FORCE_PATH_STYLE !== 'false', s3Prefix, privateS3Bucket, privateS3Prefix, awsAccessKeyId: env.AWS_ACCESS_KEY_ID, awsSecretAccessKey: env.AWS_SECRET_ACCESS_KEY, allowDiskSource, } } const resolvedStorageConfig = resolveHostingStorageConfig() const CACHE_DIR = resolvedStorageConfig.cacheDir const HOT_CACHE_SIZE = resolvedStorageConfig.hotCacheSize const HOT_CACHE_COUNT = resolvedStorageConfig.hotCacheCount const WARM_CACHE_SIZE = resolvedStorageConfig.warmCacheSize const WARM_EVICTION_POLICY = resolvedStorageConfig.warmEvictionPolicy const HOT_CACHE_TTL = resolvedStorageConfig.hotCacheTtlSeconds const S3_BUCKET = resolvedStorageConfig.s3Bucket const S3_REGION = resolvedStorageConfig.s3Region const S3_ENDPOINT = resolvedStorageConfig.s3Endpoint const S3_FORCE_PATH_STYLE = resolvedStorageConfig.s3ForcePathStyle const AWS_ACCESS_KEY_ID = resolvedStorageConfig.awsAccessKeyId const AWS_SECRET_ACCESS_KEY = resolvedStorageConfig.awsSecretAccessKey const S3_PREFIX = resolvedStorageConfig.s3Prefix const PRIVATE_BUCKET = resolvedStorageConfig.privateS3Bucket const PRIVATE_PREFIX = resolvedStorageConfig.privateS3Prefix // Identity serializers for raw binary data (no JSON transformation) // Files are stored as-is without any encoding/decoding const identitySerialize = async (data: unknown): Promise => { if (data instanceof Uint8Array) return data if (data instanceof ArrayBuffer) return new Uint8Array(data) if (Buffer.isBuffer(data)) return new Uint8Array(data) // For other types, fall back to JSON (shouldn't happen with file storage) return new TextEncoder().encode(JSON.stringify(data)) } const identityDeserialize = async (data: Uint8Array): Promise => { // Return as-is for binary file storage return data } /** Safe operation names exposed when a cold S3 read cannot complete. */ export type StorageReadOperation = | 'get' | 'getWithMetadata' | 'getStream' | 'exists' | 'getMetadata' | 'listKeys' | 'getStats' /** Safe categories for S3 read failures. */ export type StorageUnavailableKind = 'authentication' | 'network' | 'service' | 'timeout' | 'unknown' | 'circuit-open' /** * A non-authoritative S3 read failure. * * This error intentionally stores only a safe operation and category. It never * retains the driver error, object key, bucket, endpoint, or driver message. */ export class StorageUnavailableError extends Error { override readonly name = 'StorageUnavailableError' /** * @param operation The failed read operation * @param kind A safe failure category */ constructor( readonly operation: StorageReadOperation, readonly kind: StorageUnavailableKind, ) { super('Storage is temporarily unavailable') } } /** * Test whether an error is a typed non-authoritative S3 read failure. * * @param error Value to inspect * @returns True when callers should return a transient storage response */ export function isStorageUnavailableError(error: unknown): error is StorageUnavailableError { return error instanceof StorageUnavailableError } /** Constant-time safe health state for public cold S3 reads. */ export interface StorageReadHealthSnapshot { configured: boolean status: 'not-configured' | 'healthy' | 'degraded' | 'unavailable' breaker: 'closed' | 'open' | 'half-open' consecutiveFailures: number totalFailures: number totalSuccesses: number circuitRejections: number lastSuccessAt: number | null lastFailureAt: number | null lastErrorKind: StorageUnavailableKind | null lastSuccessAgeMs: number | null } type ReadPermit = { probe: boolean } type BreakerState = 'closed' | 'open' | 'half-open' const S3_READ_FAILURE_THRESHOLD = 3 const S3_READ_BREAKER_COOLDOWN_MS = 5_000 const NOT_FOUND_S3_ERROR_NAMES = new Set(['NoSuchKey', 'NoSuchVersion', 'NotFound']) const TIMEOUT_S3_ERROR_NAMES = new Set(['AbortError', 'RequestTimeout', 'TimeoutError', 'ETIMEDOUT']) const AUTHENTICATION_S3_ERROR_NAMES = new Set([ 'AccessDenied', 'CredentialsProviderError', 'ExpiredToken', 'InvalidAccessKeyId', 'SignatureDoesNotMatch', 'Unauthorized', 'EACCES', 'EPERM', ]) const NETWORK_S3_ERROR_NAMES = new Set([ 'ECONNREFUSED', 'ECONNRESET', 'EAI_AGAIN', 'ENETUNREACH', 'ENOTFOUND', 'NetworkingError', ]) function getErrorFields(error: unknown): { name?: string; code?: string; statusCode?: number } { if (!error || typeof error !== 'object') return {} try { const value = error as { name?: unknown code?: unknown $metadata?: { httpStatusCode?: unknown } } return { ...(typeof value.name === 'string' && { name: value.name }), ...(typeof value.code === 'string' && { code: value.code }), ...(typeof value.$metadata?.httpStatusCode === 'number' && { statusCode: value.$metadata.httpStatusCode }), } } catch { return {} } } function isAuthoritativeS3Miss(error: unknown): boolean { const { name, code, statusCode } = getErrorFields(error) return ( statusCode === 404 || (name !== undefined && NOT_FOUND_S3_ERROR_NAMES.has(name)) || (code !== undefined && NOT_FOUND_S3_ERROR_NAMES.has(code)) ) } function getStorageUnavailableKind(error: unknown): StorageUnavailableKind { const { name, code, statusCode } = getErrorFields(error) const candidates = [name, code].filter((candidate): candidate is string => candidate !== undefined) if ( statusCode === 401 || statusCode === 403 || candidates.some((candidate) => AUTHENTICATION_S3_ERROR_NAMES.has(candidate)) ) { return 'authentication' } if (candidates.some((candidate) => TIMEOUT_S3_ERROR_NAMES.has(candidate))) return 'timeout' if (candidates.some((candidate) => NETWORK_S3_ERROR_NAMES.has(candidate))) return 'network' if (statusCode !== undefined && statusCode >= 500 && statusCode <= 599) return 'service' return 'unknown' } function getErrorDigest(error: unknown): string { let fingerprint: string try { if (error instanceof Error) { fingerprint = `${error.name}\0${error.message}` } else if (typeof error === 'string') { fingerprint = error } else if (error && typeof error === 'object') { const value = error as { name?: unknown; code?: unknown; message?: unknown } fingerprint = `${String(value.name ?? '')}\0${String(value.code ?? '')}\0${String(value.message ?? '')}` } else { fingerprint = String(error) } } catch { fingerprint = 'unreadable-error' } return createHash('sha256').update(fingerprint.slice(0, 4096)).digest('hex').slice(0, 16) } class StorageReadHealth { private breaker: BreakerState = 'closed' private breakerOpenUntil = 0 private probeInFlight = false private consecutiveFailures = 0 private totalFailures = 0 private totalSuccesses = 0 private circuitRejections = 0 private lastSuccessAt: number | null = null private lastFailureAt: number | null = null private lastErrorKind: StorageUnavailableKind | null = null beginRead(operation: StorageReadOperation): ReadPermit | StorageUnavailableError { const now = Date.now() if (this.breaker === 'open') { if (now < this.breakerOpenUntil) { this.circuitRejections++ return new StorageUnavailableError(operation, 'circuit-open') } if (this.probeInFlight) { this.circuitRejections++ return new StorageUnavailableError(operation, 'circuit-open') } this.breaker = 'half-open' this.probeInFlight = true return { probe: true } } if (this.breaker === 'half-open') { this.circuitRejections++ return new StorageUnavailableError(operation, 'circuit-open') } return { probe: false } } recordSuccess(permit: ReadPermit): void { this.totalSuccesses++ this.consecutiveFailures = 0 this.lastSuccessAt = Date.now() this.breaker = 'closed' this.breakerOpenUntil = 0 if (permit.probe) this.probeInFlight = false } recordFailure(permit: ReadPermit, kind: StorageUnavailableKind): void { this.totalFailures++ this.consecutiveFailures++ this.lastFailureAt = Date.now() this.lastErrorKind = kind if (permit.probe) this.probeInFlight = false if (permit.probe || this.consecutiveFailures >= S3_READ_FAILURE_THRESHOLD) { this.breaker = 'open' this.breakerOpenUntil = Date.now() + S3_READ_BREAKER_COOLDOWN_MS } } abandonRead(permit: ReadPermit): void { if (!permit.probe) return this.probeInFlight = false this.breaker = 'open' } getSnapshot(configured: boolean): StorageReadHealthSnapshot { const now = Date.now() const breaker = this.breaker return { configured, status: !configured ? 'not-configured' : breaker === 'closed' ? this.consecutiveFailures > 0 ? 'degraded' : 'healthy' : 'unavailable', breaker, consecutiveFailures: this.consecutiveFailures, totalFailures: this.totalFailures, totalSuccesses: this.totalSuccesses, circuitRejections: this.circuitRejections, lastSuccessAt: this.lastSuccessAt, lastFailureAt: this.lastFailureAt, lastErrorKind: this.lastErrorKind, lastSuccessAgeMs: this.lastSuccessAt === null ? null : Math.max(0, now - this.lastSuccessAt), } } } /** * Read-only wrapper for S3 tier. * * Authoritative object absence remains `null`. Transient/authentication/network * failures are surfaced as `StorageUnavailableError` so callers cannot mistake * an unavailable source of truth for a cache miss. */ export class ReadOnlyS3Tier implements StorageTier { private static hasLoggedWriteSkip = false /** * @param tier S3-backed tier to read from * @param health Per-source health and breaker state */ constructor( private readonly tier: StorageTier, private readonly health: StorageReadHealth = new StorageReadHealth(), ) {} async get(key: string): Promise { return await this.performRead('get', () => this.tier.get(key), { missingResult: null }) } async getWithMetadata(key: string) { return await this.performRead('getWithMetadata', async () => (await this.tier.getWithMetadata?.(key)) ?? null, { missingResult: null, }) } async getStream(key: string, options?: { signal?: AbortSignal; borrowChunks?: boolean }) { return await this.performRead('getStream', async () => (await this.tier.getStream?.(key, options)) ?? null, { missingResult: null, signal: options?.signal, }) } async exists(key: string): Promise { return await this.performRead('exists', () => this.tier.exists(key), { missingResult: false }) } async getMetadata(key: string): Promise { return await this.performRead('getMetadata', () => this.tier.getMetadata(key), { missingResult: null }) } async *listKeys(prefix?: string): AsyncIterableIterator { const permit = this.health.beginRead('listKeys') if (permit instanceof StorageUnavailableError) throw permit let completed = false try { for await (const key of this.tier.listKeys(prefix)) { yield key } completed = true this.health.recordSuccess(permit) } catch (error) { throw this.handleReadFailure('listKeys', permit, error) } finally { if (!completed) this.health.abandonRead(permit) } } async getStats() { return await this.performRead('getStats', () => this.tier.getStats()) } // Write operations are no-ops: hosting service never mutates its S3 source. async set(_key: string, _data: Uint8Array, _metadata: StorageMetadata): Promise { this.logWriteSkip('set') } async setStream(_key: string, _stream: NodeJS.ReadableStream, _metadata: StorageMetadata): Promise { this.logWriteSkip('setStream') } async setMetadata(_key: string, _metadata: StorageMetadata): Promise { this.logWriteSkip('setMetadata') } async delete(_key: string): Promise { this.logWriteSkip('delete') } async deleteMany(_keys: string[]): Promise { this.logWriteSkip('deleteMany') } async clear(): Promise { this.logWriteSkip('clear') } /** * Return this wrapper's safe read-health state. * * @returns Constant-time breaker and counter snapshot */ getHealthSnapshot(): StorageReadHealthSnapshot { return this.health.getSnapshot(true) } private async performRead( operation: StorageReadOperation, work: () => Promise, options?: { missingResult: T; signal?: AbortSignal }, ): Promise { options?.signal?.throwIfAborted() const permit = this.health.beginRead(operation) if (permit instanceof StorageUnavailableError) throw permit try { const result = await work() this.health.recordSuccess(permit) return result } catch (error) { if (options?.signal?.aborted) { this.health.abandonRead(permit) throw error } if (options && isAuthoritativeS3Miss(error)) { this.health.recordSuccess(permit) return options.missingResult } throw this.handleReadFailure(operation, permit, error) } } private handleReadFailure( operation: StorageReadOperation, permit: ReadPermit, error: unknown, ): StorageUnavailableError { const kind = getStorageUnavailableKind(error) this.health.recordFailure(permit, kind) logger.warn('S3 read operation failed', { operation, errorKind: kind, errorDigest: getErrorDigest(error), }) return new StorageUnavailableError(operation, kind) } private logWriteSkip(operation: string): void { if (!ReadOnlyS3Tier.hasLoggedWriteSkip) { logger.info('Skipping S3 mutation in read-only tier', { operation }) ReadOnlyS3Tier.hasLoggedWriteSkip = true } } } const publicS3ReadHealth = new StorageReadHealth() const privateS3ReadHealth = new StorageReadHealth() /** * Get a constant-time health snapshot for the public S3 source. * * @returns Safe public cold-tier read health without issuing storage I/O */ export function getStorageReadHealthSnapshot(): StorageReadHealthSnapshot { return publicS3ReadHealth.getSnapshot(Boolean(S3_BUCKET)) } /** * Wrapper around MemoryStorageTier that enforces a short per-entry TTL. * This acts as a safety net: even if cache invalidation fails to clear the * hot tier, stale entries will expire after HOT_CACHE_TTL seconds. * * The TieredStorage defaultTTL (14 days) is too long for the hot tier - * we want stale hot entries to expire quickly and re-fetch from warm/cold. */ class TTLMemoryTier implements StorageTier { public readonly inner: MemoryStorageTier private ttlMs: number private insertedAt = new Map() constructor(config: { maxSizeBytes: number; maxItems?: number }, ttlSeconds: number) { this.inner = new MemoryStorageTier(config) this.ttlMs = ttlSeconds * 1000 } private isStale(key: string): boolean { const ts = this.insertedAt.get(key) if (ts === undefined) return false return Date.now() - ts > this.ttlMs } private async evictIfStale(key: string): Promise { if (this.isStale(key)) { await this.inner.delete(key) this.insertedAt.delete(key) return true } return false } /** Remove timestamps for entries TinyLRU evicted without notifying this wrapper. */ private async pruneMissingTimestamps(): Promise { for (const key of this.insertedAt.keys()) { if (!(await this.inner.exists(key))) { this.insertedAt.delete(key) } } } private async recordSuccessfulWrite(key: string): Promise { if (await this.inner.exists(key)) { this.insertedAt.set(key, Date.now()) } else { this.insertedAt.delete(key) } await this.pruneMissingTimestamps() } async get(key: string) { if (await this.evictIfStale(key)) return null const result = await this.inner.get(key) if (!result) this.insertedAt.delete(key) return result } async getWithMetadata(key: string) { if (await this.evictIfStale(key)) return null const result = await this.inner.getWithMetadata(key) if (!result) this.insertedAt.delete(key) return result } async getStream(key: string, options?: { signal?: AbortSignal; borrowChunks?: boolean }) { if (await this.evictIfStale(key)) return null const result = await this.inner.getStream(key, options) if (!result) this.insertedAt.delete(key) return result } async set(key: string, data: Uint8Array, metadata: StorageMetadata) { try { await this.inner.set(key, data, metadata) await this.recordSuccessfulWrite(key) } catch (error) { if (!(await this.inner.exists(key))) this.insertedAt.delete(key) throw error } } async setStream(key: string, stream: NodeJS.ReadableStream, metadata: StorageMetadata) { try { await this.inner.setStream(key, stream, metadata) await this.recordSuccessfulWrite(key) } catch (error) { if (!(await this.inner.exists(key))) this.insertedAt.delete(key) throw error } } async delete(key: string) { this.insertedAt.delete(key) return this.inner.delete(key) } async deleteMany(keys: string[]) { for (const key of keys) this.insertedAt.delete(key) return this.inner.deleteMany(keys) } async exists(key: string) { if (await this.evictIfStale(key)) return false const exists = await this.inner.exists(key) if (!exists) this.insertedAt.delete(key) return exists } async *listKeys(prefix?: string) { yield* this.inner.listKeys(prefix) } async getMetadata(key: string) { if (await this.evictIfStale(key)) return null const metadata = await this.inner.getMetadata(key) if (!metadata) this.insertedAt.delete(key) return metadata } async setMetadata(key: string, metadata: StorageMetadata) { await this.inner.setMetadata(key, metadata) if (!(await this.inner.exists(key))) this.insertedAt.delete(key) } async getStats() { await this.pruneMissingTimestamps() return this.inner.getStats() } async clear() { this.insertedAt.clear() return this.inner.clear() } } // Exported for direct access during cache invalidation export let hotTier: TTLMemoryTier export let warmTier: StorageTier | undefined let sharedDiskTier: DiskStorageTier | undefined const s3Tiers: S3StorageTier[] = [] function getSharedDiskTier(): DiskStorageTier { sharedDiskTier ??= new DiskStorageTier({ directory: CACHE_DIR, maxSizeBytes: WARM_CACHE_SIZE, evictionPolicy: WARM_EVICTION_POLICY, encodeColons: false, // Preserve colons for readable DID paths on Unix/macOS }) return sharedDiskTier } /** * Initialize tiered storage * Must be called before serving requests */ function initializeStorage(): TieredStorage { // Determine cold tier: S3 if configured, otherwise disk acts as cold let coldTier: StorageTier const diskTier = getSharedDiskTier() if (S3_BUCKET) { // Full three-tier setup with S3 as cold storage const s3Tier = new S3StorageTier({ bucket: S3_BUCKET, region: S3_REGION, endpoint: S3_ENDPOINT, forcePathStyle: S3_FORCE_PATH_STYLE, credentials: AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY ? { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } : undefined, prefix: S3_PREFIX, }) s3Tiers.push(s3Tier) // Hosting service is read-only: always wrap S3 tier to make it read-only coldTier = new ReadOnlyS3Tier(s3Tier, publicS3ReadHealth) warmTier = diskTier logger.info('Configured S3 as the read-only cold tier', { mode: 's3' }) } else { // Disk-only mode: disk tier acts as source of truth (cold) coldTier = diskTier warmTier = undefined logger.info('Configured disk as the single-node cold tier', { mode: 'disk' }) } // Hot tier with short TTL - entries expire quickly so stale data doesn't persist hotTier = new TTLMemoryTier({ maxSizeBytes: HOT_CACHE_SIZE, maxItems: HOT_CACHE_COUNT }, HOT_CACHE_TTL) logger.info('Configured hot cache TTL', { ttlSeconds: HOT_CACHE_TTL }) const storage = new TieredStorage({ tiers: { // Hot tier: In-memory LRU with short TTL for instant serving hot: hotTier, // Warm tier: Disk-based cache (only when S3 is configured) warm: warmTier, // Cold tier: S3/R2 as source of truth, or disk in disk-only mode cold: coldTier, }, // Placement rules: determine which tiers each file goes to placementRules: [ // Rewritten HTML: keep hot for fast serving { pattern: '**/.rewritten/**/*.html', tiers: ['hot', 'warm', 'cold'], }, // index.html is critical: write to all tiers for instant serving { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'], }, { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'], }, // CSS and JS: eligible for hot tier if accessed frequently { pattern: '**/*.{css,js}', tiers: ['hot', 'warm', 'cold'], }, // Media files: never needed in memory, skip hot tier { pattern: '**/*.{jpg,jpeg,png,gif,webp,svg,ico,mp4,webm,mp3,woff,woff2,ttf,eot}', tiers: ['warm', 'cold'], }, // Default: everything else goes to warm and cold { pattern: '**', tiers: ['warm', 'cold'], }, ], // IMPORTANT: Compression is disabled at the tiered-storage level // Text files (HTML, CSS, JS, JSON) are pre-compressed with gzip at the app level // Binary files (images, video) are stored uncompressed as they're already compressed // The file's compression state is tracked in customMetadata.encoding compression: false, // TTL for cache entries (14 days) defaultTTL: 14 * 24 * 60 * 60 * 1000, // Eager promotion: promote data to upper tiers on read // This ensures frequently accessed files end up in hot tier promotionStrategy: 'eager', // Identity serialization: store raw binary without JSON transformation serialization: { serialize: identitySerialize, deserialize: identityDeserialize, }, }) return storage } // Export singleton instance export const storage = initializeStorage() /** * Add source-CID provenance only when the cold object still matches the bytes * just verified by the caller. A false result is safe to repair by download. */ export async function addPublicSourceCidIfChecksumMatches( key: string, expectedChecksum: string, sourceCid: string, ): Promise { return await storage.addSourceCidIfChecksumMatches(key, expectedChecksum, sourceCid) } /** * Evict one public file from local cache tiers without deleting the source copy. * * This is used when cached metadata does not match the manifest. It never * touches the cold tier, including S3 and disk-only cold storage. The underlying * operation is ordered with eager promotion so concurrent evictions cannot be * undone by a stale lower-tier read. */ export async function evictPublicCacheKey(key: string): Promise { const failures = await storage.invalidateUpperCacheKey(key) if (failures.length > 0) { const tiers = failures.map(({ tier }) => tier).join(', ') throw new Error(`Failed to evict local cache tier${failures.length === 1 ? '' : 's'}: ${tiers}`) } } // Avoid promotion into shared caches that outlive the authorization decision. function initializePrivateStorage(): TieredStorage { let coldTier: StorageTier if (PRIVATE_BUCKET) { const s3Tier = new S3StorageTier({ bucket: PRIVATE_BUCKET, region: S3_REGION, endpoint: S3_ENDPOINT, forcePathStyle: S3_FORCE_PATH_STYLE, credentials: AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY ? { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } : undefined, prefix: PRIVATE_PREFIX, }) s3Tiers.push(s3Tier) coldTier = new ReadOnlyS3Tier(s3Tier, privateS3ReadHealth) } else { // Public and private disk-only reads share one index and mutation queue. // Private keys remain under their existing `private/` namespace. coldTier = getSharedDiskTier() } return new TieredStorage({ tiers: { cold: coldTier }, compression: false, promotionStrategy: 'lazy', serialization: { serialize: identitySerialize, deserialize: identityDeserialize, }, }) } export const privateStorage = initializePrivateStorage() /** Acquire providers before both storage engines, so providers close only after their users drain. */ export const acquireStorage = Effect.gen(function* () { if (sharedDiskTier) yield* acquireDiskStorageTier(sharedDiskTier) for (const tier of s3Tiers) yield* acquireS3StorageTier(tier) yield* acquireTieredStorage(storage) yield* acquireTieredStorage(privateStorage) }) /** * Get a safe storage configuration summary for application logs and startup. * Object-store identifiers and prefixes are deliberately excluded. */ export function getStorageConfig() { return { cacheDir: CACHE_DIR, hotCacheSize: `${(HOT_CACHE_SIZE / 1024 / 1024).toFixed(0)}MB`, hotCacheCount: HOT_CACHE_COUNT, warmCacheSize: `${(WARM_CACHE_SIZE / 1024 / 1024 / 1024).toFixed(1)}GB`, warmEvictionPolicy: WARM_EVICTION_POLICY, coldStorageMode: S3_BUCKET ? 's3' : 'disk', s3EndpointConfigured: Boolean(S3_ENDPOINT), diskSourceAllowed: resolvedStorageConfig.allowDiskSource, } }