Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
|
* Pluggable storage backend (Phase 6a — see docs/berth-recommender-and-pdf-plan.md §4.7a).
|
|
*
|
|
* The CRM stores files (per-berth PDFs, brochures, GDPR exports, etc.) through a
|
|
* single `StorageBackend` abstraction. The deployment chooses between an
|
|
* S3-compatible store (MinIO / AWS S3 / Backblaze B2 / Cloudflare R2 / Wasabi /
|
|
* Tigris) and a local filesystem at runtime via `system_settings.storage_backend`.
|
|
*
|
|
* Callers should always import from this barrel — never from `s3.ts` or
|
|
* `filesystem.ts` directly — so the factory wiring stays the single source of
|
|
* truth.
|
|
*/
|
|
|
|
import { and, eq, isNull } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { systemSettings } from '@/lib/db/schema/system';
|
|
import { logger } from '@/lib/logger';
|
|
|
|
import { FilesystemBackend } from './filesystem';
|
|
import { S3Backend } from './s3';
|
|
|
|
export type StorageBackendName = 's3' | 'filesystem';
|
|
|
|
export interface PutOpts {
|
|
contentType: string;
|
|
/** Optional pre-computed sha256 hex — if absent, the backend computes one. */
|
|
sha256?: string;
|
|
/** Bytes (for streams that don't expose .length); used for capacity pre-flight. */
|
|
sizeBytes?: number;
|
|
/** Optional content-disposition for downloads (filesystem proxy only). */
|
|
contentDisposition?: string;
|
|
}
|
|
|
|
export interface PresignOpts {
|
|
/** TTL in seconds. Default 900 (15min) per SECURITY-GUIDELINES §7.1. */
|
|
expirySeconds?: number;
|
|
contentType?: string;
|
|
/** Filename used in Content-Disposition for downloads. */
|
|
filename?: string;
|
|
/**
|
|
* Optional port slug to bind the token to. The filesystem proxy
|
|
* verifier asserts the storage key starts with `${portSlug}/` when
|
|
* present. S3 backend ignores this field (presigned S3 URLs carry
|
|
* their own signature scope). Pass it whenever the issuer is in a
|
|
* port-scoped request — `generateStorageKey()` already prefixes the
|
|
* slug, so this is the matching enforcement.
|
|
*/
|
|
portSlug?: string;
|
|
}
|
|
|
|
export interface StorageBackend {
|
|
/** Upload bytes. Returns the canonical key, size, and sha256 hex. */
|
|
put(
|
|
key: string,
|
|
body: Buffer | NodeJS.ReadableStream,
|
|
opts: PutOpts,
|
|
): Promise<{ key: string; sizeBytes: number; sha256: string }>;
|
|
|
|
/** Stream a file out. Throws NotFoundError if missing. */
|
|
get(key: string): Promise<NodeJS.ReadableStream>;
|
|
|
|
/** Existence + size check without reading the full body. Returns null when missing. */
|
|
head(key: string): Promise<{ sizeBytes: number; contentType: string } | null>;
|
|
|
|
/** Delete. Idempotent — missing keys must not throw. */
|
|
delete(key: string): Promise<void>;
|
|
|
|
/** Generate a short-lived URL the browser can PUT to. */
|
|
presignUpload(key: string, opts: PresignOpts): Promise<{ url: string; method: 'PUT' | 'POST' }>;
|
|
|
|
/** Generate a short-lived URL the browser can GET from. */
|
|
presignDownload(key: string, opts: PresignOpts): Promise<{ url: string; expiresAt: Date }>;
|
|
|
|
/**
|
|
* Recursively list keys under `prefix`. Returns the relative key for each
|
|
* object, sorted alphabetically. Empty prefix means "the entire bucket /
|
|
* storage root". Used by one-shot importers (e.g. organized-bucket
|
|
* document import) that need to walk a flat key namespace; not meant for
|
|
* runtime hot paths.
|
|
*/
|
|
listByPrefix(prefix: string): Promise<string[]>;
|
|
|
|
readonly name: StorageBackendName;
|
|
}
|
|
|
|
// ─── factory ────────────────────────────────────────────────────────────────
|
|
|
|
interface CachedFactory {
|
|
backend: StorageBackend;
|
|
/** Resolved at cache-time so we can re-fetch when settings change. */
|
|
configFingerprint: string;
|
|
}
|
|
|
|
let cached: CachedFactory | null = null;
|
|
|
|
/**
|
|
* Reset the per-process backend cache. Called after `system_settings` writes
|
|
* via the existing pub/sub invalidation hook, and exposed for tests.
|
|
*/
|
|
export function resetStorageBackendCache(): void {
|
|
cached = null;
|
|
}
|
|
|
|
interface StorageConfigSnapshot {
|
|
backend: StorageBackendName;
|
|
s3?: {
|
|
endpoint?: string;
|
|
region?: string;
|
|
bucket?: string;
|
|
accessKey?: string;
|
|
secretKeyEncrypted?: string;
|
|
forcePathStyle?: boolean;
|
|
};
|
|
filesystem?: {
|
|
root?: string;
|
|
proxyHmacSecretEncrypted?: string;
|
|
};
|
|
}
|
|
|
|
async function readGlobalSetting<T = unknown>(key: string): Promise<T | null> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(systemSettings)
|
|
.where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
|
|
return (row?.value as T | undefined) ?? null;
|
|
}
|
|
|
|
async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
|
|
// Each setting key is a separate row. We read them in parallel.
|
|
// `storage_s3_access_key_encrypted` is the modern home for the S3 access
|
|
// key (fixes audit S-23 — was previously stored plaintext at
|
|
// `storage_s3_access_key`). We still read the legacy plaintext key for
|
|
// backward compat, but the encrypted form wins when both are present.
|
|
const keys = [
|
|
'storage_backend',
|
|
'storage_s3_endpoint',
|
|
'storage_s3_region',
|
|
'storage_s3_bucket',
|
|
'storage_s3_access_key', // legacy plaintext (kept for backward compat)
|
|
'storage_s3_access_key_encrypted', // modern AES envelope
|
|
'storage_s3_secret_key_encrypted',
|
|
'storage_s3_force_path_style',
|
|
'storage_filesystem_root',
|
|
'storage_proxy_hmac_secret_encrypted',
|
|
] as const;
|
|
const [
|
|
backendRaw,
|
|
s3Endpoint,
|
|
s3Region,
|
|
s3Bucket,
|
|
s3AccessKeyLegacy,
|
|
s3AccessKeyEncryptedRaw,
|
|
s3SecretKeyEncrypted,
|
|
s3ForcePathStyle,
|
|
fsRoot,
|
|
fsHmacSecretEncrypted,
|
|
] = await Promise.all(keys.map((k) => readGlobalSetting<unknown>(k)));
|
|
|
|
const backend: StorageBackendName = backendRaw === 'filesystem' ? 'filesystem' : 's3';
|
|
|
|
// Prefer the encrypted form. Decrypt inline so downstream `S3Backend.create`
|
|
// still receives the cleartext under the existing `accessKey` field.
|
|
let accessKey: string | undefined;
|
|
if (s3AccessKeyEncryptedRaw && typeof s3AccessKeyEncryptedRaw === 'object') {
|
|
const env = s3AccessKeyEncryptedRaw as { iv?: string; tag?: string; data?: string };
|
|
if (env.iv && env.tag && env.data) {
|
|
try {
|
|
accessKey = (await import('@/lib/utils/encryption')).decrypt(
|
|
JSON.stringify(s3AccessKeyEncryptedRaw),
|
|
);
|
|
} catch (err) {
|
|
logger.error({ err }, 'Failed to decrypt storage_s3_access_key_encrypted');
|
|
}
|
|
}
|
|
}
|
|
if (!accessKey && typeof s3AccessKeyLegacy === 'string') {
|
|
accessKey = s3AccessKeyLegacy;
|
|
}
|
|
|
|
return {
|
|
backend,
|
|
s3: {
|
|
endpoint: typeof s3Endpoint === 'string' ? s3Endpoint : undefined,
|
|
region: typeof s3Region === 'string' ? s3Region : undefined,
|
|
bucket: typeof s3Bucket === 'string' ? s3Bucket : undefined,
|
|
accessKey,
|
|
secretKeyEncrypted:
|
|
typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined,
|
|
forcePathStyle:
|
|
typeof s3ForcePathStyle === 'boolean' ? s3ForcePathStyle : Boolean(s3ForcePathStyle),
|
|
},
|
|
filesystem: {
|
|
root: typeof fsRoot === 'string' ? fsRoot : undefined,
|
|
proxyHmacSecretEncrypted:
|
|
typeof fsHmacSecretEncrypted === 'string' ? fsHmacSecretEncrypted : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The fingerprint includes encrypted-secret material because rotating the
|
|
* secret should invalidate the cached client. After a key rotation the
|
|
* settings-write hook calls `resetStorageBackendCache()` explicitly, so
|
|
* this comparison is a defense-in-depth backstop rather than the primary
|
|
* invalidation path. If you ever change `loadStorageConfig` to read
|
|
* additional sensitive material, make sure the rotation flow keeps
|
|
* resetting the cache — relying on fingerprint diff alone means the old
|
|
* client is held in memory until the next mismatch.
|
|
*/
|
|
function fingerprint(cfg: StorageConfigSnapshot): string {
|
|
return JSON.stringify(cfg);
|
|
}
|
|
|
|
/**
|
|
* Resolve the active backend. Caches per-process; the cache is invalidated by
|
|
* `resetStorageBackendCache()` (called when `system_settings.storage_backend`
|
|
* changes via the migration flow).
|
|
*/
|
|
export async function getStorageBackend(): Promise<StorageBackend> {
|
|
const cfg = await loadStorageConfig();
|
|
const fp = fingerprint(cfg);
|
|
if (cached && cached.configFingerprint === fp) {
|
|
return cached.backend;
|
|
}
|
|
|
|
const backend = await buildBackend(cfg);
|
|
cached = { backend, configFingerprint: fp };
|
|
logger.info({ backend: backend.name }, 'Storage backend resolved');
|
|
return backend;
|
|
}
|
|
|
|
async function buildBackend(cfg: StorageConfigSnapshot): Promise<StorageBackend> {
|
|
if (cfg.backend === 'filesystem') {
|
|
return FilesystemBackend.create({
|
|
root: cfg.filesystem?.root ?? './storage',
|
|
proxyHmacSecretEncrypted: cfg.filesystem?.proxyHmacSecretEncrypted ?? null,
|
|
});
|
|
}
|
|
return S3Backend.create({
|
|
endpoint: cfg.s3?.endpoint,
|
|
region: cfg.s3?.region,
|
|
bucket: cfg.s3?.bucket,
|
|
accessKey: cfg.s3?.accessKey,
|
|
secretKeyEncrypted: cfg.s3?.secretKeyEncrypted,
|
|
forcePathStyle: cfg.s3?.forcePathStyle,
|
|
});
|
|
}
|
|
|
|
// ─── url helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Convenience wrapper that returns just the presigned download URL — the most
|
|
* common need at call sites that don't track expiry. Mirrors the legacy
|
|
* `getPresignedUrl(key)` helper in `@/lib/minio` but routes through the
|
|
* active backend so filesystem-mode deployments work too.
|
|
*
|
|
* storage-pathing-auditor H2: when `portSlug` is not passed explicitly,
|
|
* we attempt to infer it from the key's first path segment — every
|
|
* storage key minted via `buildStoragePath(slug, …)` starts with the
|
|
* slug, so the inference is correct for the overwhelming majority of
|
|
* callers. This engages the filesystem-proxy port-binding token (`p`)
|
|
* verifier so a stolen-token / cross-port replay attempt fails fast.
|
|
*/
|
|
export async function presignDownloadUrl(
|
|
key: string,
|
|
expirySeconds = 900,
|
|
filename?: string,
|
|
portSlug?: string,
|
|
): Promise<string> {
|
|
const backend = await getStorageBackend();
|
|
const inferredSlug = portSlug ?? inferPortSlugFromKey(key);
|
|
const { url } = await backend.presignDownload(key, {
|
|
expirySeconds,
|
|
filename,
|
|
portSlug: inferredSlug,
|
|
});
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Best-effort recovery of the port slug from a storage key prefix.
|
|
* Returns undefined when the key doesn't look slug-prefixed (e.g. legacy
|
|
* keys minted before `buildStoragePath` was canonical) so the caller
|
|
* falls back to the no-binding path.
|
|
*
|
|
* A slug is conservatively defined as kebab/alphanumeric (the same
|
|
* shape `createPortSchema` enforces). Non-matching first segments
|
|
* include UUID-only keys like the legacy `berths/{id}/uploads/...`
|
|
* shape — those are still served but skip the binding gate.
|
|
*/
|
|
function inferPortSlugFromKey(key: string): string | undefined {
|
|
const slash = key.indexOf('/');
|
|
if (slash <= 0) return undefined;
|
|
const first = key.slice(0, slash);
|
|
if (!/^[a-z0-9-]+$/.test(first)) return undefined;
|
|
// Reserved namespaces that historically lived at the top level of the
|
|
// bucket and aren't port slugs.
|
|
if (first === 'berths' || first === 'backups' || first === 'tmp') return undefined;
|
|
return first;
|
|
}
|
|
|
|
// ─── re-exports ─────────────────────────────────────────────────────────────
|
|
|
|
export { S3Backend } from './s3';
|
|
export { FilesystemBackend, validateStorageKey } from './filesystem';
|