chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
* - Storage keys are validated against `^[a-zA-Z0-9/_.-]+$`. Anything containing
|
||||
* `..` or that resolves to an absolute path is rejected.
|
||||
* - The resolved path is checked with `path.resolve` against the resolved
|
||||
* storage root (realpath form) — symlink escapes are blocked.
|
||||
* storage root (realpath form) - symlink escapes are blocked.
|
||||
* - The storage root is created with mode `0o700` (owner only).
|
||||
* - Refuses to start when `MULTI_NODE_DEPLOYMENT === 'true'` — multi-node
|
||||
* - Refuses to start when `MULTI_NODE_DEPLOYMENT === 'true'` - multi-node
|
||||
* deployments must use an S3-compatible store.
|
||||
* - Proxy download URLs carry an HMAC-signed payload (key + expiry); the
|
||||
* route refuses to stream a key whose token doesn't verify.
|
||||
@@ -43,7 +43,7 @@ const VALID_KEY_RE = /^[a-zA-Z0-9/_.-]+$/;
|
||||
* - segments starting with `.` (hidden files / dotfiles other than the
|
||||
* intentional dot-extension at the end)
|
||||
*
|
||||
* Use this both at write time AND at read time — a key fed back from the DB
|
||||
* Use this both at write time AND at read time - a key fed back from the DB
|
||||
* could in theory have been tampered with at rest.
|
||||
*/
|
||||
export function validateStorageKey(key: string): void {
|
||||
@@ -76,7 +76,7 @@ export function validateStorageKey(key: string): void {
|
||||
* `presignUpload` and only accepted by the proxy PUT handler. Without this
|
||||
* binding a long-lived 24h download URL emailed to a customer could be
|
||||
* replayed against the PUT handler to overwrite the original storage object
|
||||
* (since both routes share an HMAC and key — the magic-byte check is also
|
||||
* (since both routes share an HMAC and key - the magic-byte check is also
|
||||
* skipped when `c` is unset).
|
||||
*/
|
||||
export type ProxyTokenOp = 'get' | 'put';
|
||||
@@ -101,7 +101,7 @@ interface ProxyTokenPayload {
|
||||
* Port-binding: the port slug the issuer was scoped to when minting
|
||||
* the token. The verifier asserts the storage key starts with
|
||||
* `${p}/`. Defense-in-depth against a buggy issuer in some future
|
||||
* code path that mixes up port scopes — every storage key generated
|
||||
* code path that mixes up port scopes - every storage key generated
|
||||
* by `generateStorageKey()` already prefixes the port slug, so this
|
||||
* check costs nothing and catches any drift. Optional for backwards
|
||||
* compatibility with tokens minted before this field shipped; new
|
||||
@@ -177,7 +177,7 @@ export function verifyProxyToken(
|
||||
return { ok: false, reason: 'op-mismatch' };
|
||||
}
|
||||
// Port-binding: when the issuer attached `p`, assert the key starts
|
||||
// with `${p}/`. This is the actual enforcement — `validateStorageKey`
|
||||
// with `${p}/`. This is the actual enforcement - `validateStorageKey`
|
||||
// already prevents path traversal but doesn't constrain which port's
|
||||
// namespace the key belongs to. Tokens without `p` skip this check
|
||||
// (legacy / non-port-scoped issuers continue to work).
|
||||
@@ -210,7 +210,7 @@ export class FilesystemBackend implements StorageBackend {
|
||||
|
||||
/** Throws if multi-node mode is set or the root isn't writable. */
|
||||
static async create(cfg: FilesystemConfig): Promise<FilesystemBackend> {
|
||||
// Read from the zod-validated env, not raw process.env — a typo
|
||||
// Read from the zod-validated env, not raw process.env - a typo
|
||||
// (MULTI_NODE_DEPLOY=true, MULTINODE_DEPLOYMENT=true) used to silently
|
||||
// pass the string-equality check, leaving the multi-node guard
|
||||
// disabled. The schema in src/lib/env.ts now coerces the value and
|
||||
@@ -289,7 +289,7 @@ export class FilesystemBackend implements StorageBackend {
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) return null;
|
||||
// Filesystem doesn't track content-type. Caller should consult the DB
|
||||
// (or sniff via ext) — we return application/octet-stream as a default.
|
||||
// (or sniff via ext) - we return application/octet-stream as a default.
|
||||
return { sizeBytes: stat.size, contentType: extToContentType(target) };
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
@@ -394,12 +394,12 @@ export class FilesystemBackend implements StorageBackend {
|
||||
return out.map((abs) => path.relative(this.rootResolved, abs).split(path.sep).join('/')).sort();
|
||||
}
|
||||
|
||||
/** Used by the proxy route — returns the validated absolute path. */
|
||||
/** Used by the proxy route - returns the validated absolute path. */
|
||||
resolveKeyForProxy(key: string): string {
|
||||
return this.resolveKey(key);
|
||||
}
|
||||
|
||||
/** Used by the proxy route — same HMAC secret as presignDownload. */
|
||||
/** Used by the proxy route - same HMAC secret as presignDownload. */
|
||||
getHmacSecret(): string {
|
||||
return this.hmacSecret;
|
||||
}
|
||||
@@ -432,7 +432,7 @@ function resolveHmacSecret(encryptedSecret: string | null): string {
|
||||
const derived = createHash('sha256').update(`storage-proxy:${seed}`).digest('hex');
|
||||
// Warn once at boot so two processes started with different
|
||||
// `BETTER_AUTH_SECRET` values are observable: tokens minted by one
|
||||
// wouldn't validate on the other otherwise — which surfaces as random
|
||||
// wouldn't validate on the other otherwise - which surfaces as random
|
||||
// 401s on file downloads in dev.
|
||||
logger.warn(
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Pluggable storage backend (Phase 6a — see docs/berth-recommender-and-pdf-plan.md §4.7a).
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ export type StorageBackendName = 's3' | 'filesystem';
|
||||
|
||||
export interface PutOpts {
|
||||
contentType: string;
|
||||
/** Optional pre-computed sha256 hex — if absent, the backend computes one. */
|
||||
/** 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;
|
||||
@@ -43,7 +43,7 @@ export interface PresignOpts {
|
||||
* 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
|
||||
* port-scoped request - `generateStorageKey()` already prefixes the
|
||||
* slug, so this is the matching enforcement.
|
||||
*/
|
||||
portSlug?: string;
|
||||
@@ -63,7 +63,7 @@ export interface StorageBackend {
|
||||
/** 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. Idempotent - missing keys must not throw. */
|
||||
delete(key: string): Promise<void>;
|
||||
|
||||
/** Generate a short-lived URL the browser can PUT to. */
|
||||
@@ -129,7 +129,7 @@ async function readGlobalSetting<T = unknown>(key: string): Promise<T | 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
|
||||
// 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 = [
|
||||
@@ -205,7 +205,7 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
|
||||
* 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
|
||||
* 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 {
|
||||
@@ -250,13 +250,13 @@ async function buildBackend(cfg: StorageConfigSnapshot): Promise<StorageBackend>
|
||||
// ─── url helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convenience wrapper that returns just the presigned download URL — the most
|
||||
* 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
|
||||
* 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`)
|
||||
@@ -287,7 +287,7 @@ export async function presignDownloadUrl(
|
||||
* 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.
|
||||
* shape - those are still served but skip the binding gate.
|
||||
*/
|
||||
function inferPortSlugFromKey(key: string): string | undefined {
|
||||
const slash = key.indexOf('/');
|
||||
|
||||
@@ -37,12 +37,12 @@ export interface StorageKeyTable {
|
||||
* Column naming is intentionally inconsistent across the schema for historical
|
||||
* reasons:
|
||||
* - `files.storage_path` (oldest table, named before §4.7a rename)
|
||||
* - `berth_pdf_versions.storage_key` (Phase 6b — followed the new convention)
|
||||
* - `berth_pdf_versions.storage_key` (Phase 6b - followed the new convention)
|
||||
* - `brochure_versions.storage_key` (Phase 6b)
|
||||
* - `gdpr_exports.storage_key` (worker-uploaded export bundle)
|
||||
*
|
||||
* None of these tables carry a per-row content-type column today
|
||||
* (`files.mime_type` exists but isn't the same semantics — it's the
|
||||
* (`files.mime_type` exists but isn't the same semantics - it's the
|
||||
* original-upload mime, not the stored object's Content-Type header). The
|
||||
* migration falls back to `application/octet-stream` when
|
||||
* `contentTypeColumn` is omitted; the byte stream is what matters for the
|
||||
@@ -58,7 +58,7 @@ export const TABLES_WITH_STORAGE_KEYS: StorageKeyTable[] = [
|
||||
{ table: 'brochure_versions', keyColumn: 'storage_key', pkColumn: 'id' },
|
||||
{ table: 'gdpr_exports', keyColumn: 'storage_key', pkColumn: 'id' },
|
||||
// Last-resort recovery: pg_dump artefacts from the BackupService. The
|
||||
// audit caught these were missing — flipping the storage backend used
|
||||
// audit caught these were missing - flipping the storage backend used
|
||||
// to silently orphan every backup, dark-blacking the recovery path.
|
||||
{ table: 'backup_jobs', keyColumn: 'storage_path', pkColumn: 'id' },
|
||||
];
|
||||
@@ -242,7 +242,7 @@ export interface MigrationOptions {
|
||||
dryRun: boolean;
|
||||
/** Skip the file copy and just flip the active backend pointer.
|
||||
* Existing files become inaccessible until they're migrated later
|
||||
* or the backend is reverted. Rare — surfaced in the admin UI as
|
||||
* or the backend is reverted. Rare - surfaced in the admin UI as
|
||||
* a clearly-warned alternative to switch + migrate. */
|
||||
skipMigration?: boolean;
|
||||
/** Override for tests. */
|
||||
|
||||
@@ -35,7 +35,7 @@ interface S3BackendConfig {
|
||||
* `fetchWithTimeout` semantics into `putObject` / `getObject` / `statObject`
|
||||
* (its underlying `node:http(s)` agent has no default request-timeout), so a
|
||||
* TCP-blackhole between the worker and the storage host can stall a job
|
||||
* indefinitely. We race every call against a deadline and fail loud — the
|
||||
* indefinitely. We race every call against a deadline and fail loud - the
|
||||
* caller's retry/error path is far better than a stuck queue worker.
|
||||
*
|
||||
* The MinIO client doesn't accept an AbortSignal on these methods, so the
|
||||
@@ -119,7 +119,7 @@ function parseEndpoint(endpoint: string | undefined): {
|
||||
const port = url.port ? Number(url.port) : useSSL ? 443 : 80;
|
||||
return { endPoint: url.hostname, port, useSSL };
|
||||
} catch {
|
||||
// Not a URL — treat as bare hostname and use defaults.
|
||||
// Not a URL - treat as bare hostname and use defaults.
|
||||
return {
|
||||
endPoint: endpoint,
|
||||
port: env.MINIO_PORT ?? 9000,
|
||||
@@ -166,7 +166,7 @@ export class S3Backend implements StorageBackend {
|
||||
// error surfaces with a clear message instead of as a vague Minio
|
||||
// error inside the first user-facing request that touches storage.
|
||||
// Logged-not-thrown when MINIO_AUTO_CREATE_BUCKET=true and the bucket
|
||||
// is missing — we'll create it. Otherwise we throw so the boot fails
|
||||
// is missing - we'll create it. Otherwise we throw so the boot fails
|
||||
// fast and the deployment-time misconfig is loud.
|
||||
try {
|
||||
const exists = await withTimeout(
|
||||
@@ -211,7 +211,7 @@ export class S3Backend implements StorageBackend {
|
||||
): Promise<{ key: string; sizeBytes: number; sha256: string }> {
|
||||
// We need both upload + a sha256 of the bytes. For a Buffer this is trivial;
|
||||
// for a stream we pipe through a hash transform. We buffer streams up to
|
||||
// a reasonable cap (memory pressure is acceptable here — typical files
|
||||
// a reasonable cap (memory pressure is acceptable here - typical files
|
||||
// are under 50MB and the alternative is a temp-file dance).
|
||||
const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
|
||||
const sha256 = opts.sha256 ?? createHash('sha256').update(buffer).digest('hex');
|
||||
@@ -219,7 +219,7 @@ export class S3Backend implements StorageBackend {
|
||||
await withTimeout(
|
||||
this.client.putObject(this.bucket, key, buffer, buffer.length, {
|
||||
'Content-Type': opts.contentType,
|
||||
// Force server-side encryption for every blob — signed contracts,
|
||||
// Force server-side encryption for every blob - signed contracts,
|
||||
// GDPR exports, pg_dumps, EOI PDFs all otherwise land at rest in
|
||||
// cleartext unless the bucket has default-encryption configured.
|
||||
// The audit's S3-pathing CRITICAL was that this was missing.
|
||||
|
||||
Reference in New Issue
Block a user