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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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(
{

View File

@@ -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('/');

View File

@@ -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. */

View File

@@ -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.