v1.0 #1

Merged
xavier merged 92 commits from dev into main 2026-08-10 18:58:19 +02:00
Owner
No description provided.
Bootstrap the specification set under docs/specs/ covering the v1
scope agreed during refinement:

- auth-session: login, storage toggle, 3-day timeout, single-connection rule
- s3-navigation: bucket/prefix browsing, pagination, address-style detection
- object-transfer: multipart upload, proxy-zip multi-download
- object-metadata: free list-view metadata + on-select inspector panel
- data-inspection: CSV/TSV/JSON/Parquet/xlsx schema with clean-cut caps
- sql-query: read-only duckdb-wasm SQL over CSV/Parquet/JSON
- object-operations: delete, create folder, rename/move, copy
- backend-proxy: stateless relay, address-style caching, temp-storage cleanup
- deployment-config: operator-tunable knobs inventory
- ui-shell: theme, responsive, browser support
- GLOSSARY + README index

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Vendored snapshot of Xavier Reveillon's canonical xreveillon design system (Terminal direction, JetBrains Mono, soft-contrast light/dark palette). The snapshot is the binding source of truth for the project's visual identity — see docs/specs/ui-shell.md.

Contents:
- DESIGN_SYSTEM.md   — human + agent reference
- tokens.css          — CSS variables (light + dark via [data-theme])
- tailwind.config.js  — Tailwind v3 preset (semantic colors → CSS vars)
- palette.json        — tokens in JSON
- README.md           — snapshot status (may drift from canonical)

Deliberate divergences from canonical (accepted per docs/specs/ui-shell.md): translated to English; removed references to other projects/companies (incl. Edda). Build-time consumption mechanism (imported tokens, tailwind preset, etc.) is an architect decision.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Cross-spec refinement batch continuing the harmonization themes from a9c628d.

GLOSSARY: S3-accurate Prefix definition (arbitrary leading substring, not 'up to a /'); harmonized 'hard-stop' to 'hard limit' terminology.
auth-session: SESSION_TIMEOUT reframed as a sliding inactivity window (refreshed on session load), not a fixed lifetime; linked to deployment-config.
backend-proxy: corrected the endpoint-allowlist mechanism — a front-facing reverse proxy can't enforce it (target endpoint is embedded in app payloads); network egress policy is the right tool.
data-inspection: partial-row guarantee restated; enforcement point (proxy-side clean cut vs frontend detection) left as an architect decision.
sql-query: caps renamed to SQL_QUERY_WARN_SIZE / SQL_QUERY_LIMIT_SIZE to match deployment-config; warn default 500 MB to 250 MB.

deployment-config + object-transfer: added OBJECT_UPLOAD_LIMIT_SIZE (per-object hard cap, default 50 GB, must stay >= 5 GB to preserve multipart capability) and OBJECT_DOWNLOAD_LIMIT_SIZE (hard cap on the estimated total of a selection — single or bulk/zip; sum of LIST/HEAD sizes). Both enforced before the transfer starts: no partial multipart initiated, no zip staging.

ui-shell: pinned visual identity to the vendored design-system/ snapshot (binding source of truth, may drift from canonical xreveillon); light/dark themes must derive from tokens.css; project-specific deltas placeholder left for the frontend engineer to fill during implementation.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
DECISIONS.md — 16 load-bearing ADRs: streaming byte-relay proxy, zip with no temp file (in-memory central directory), single-session-per-browser (last-login-wins, client-enforced), one-transfer-at-a-time browser-wide, phased v1.0/v1.1, React 19 + Vite 8 + Fastify 5 on Node 24 LTS, duckdb-wasm pinned to stable 1.32.0 (npm 'latest' is a dev build).

ARCHITECTURE.md — topology, data-flow sequences for login/navigation/multipart upload/bulk zip/metadata range-fetch, verified tech-stack table (all versions checked against live registries Aug 2026), deployment view, phased delivery, risks register.

COMPONENTS.md — per-component responsibilities, interfaces, dependencies for web (App Shell, Session Manager, Transfer Engine, S3 Data Client, List View, Metadata Inspector; v1.1: SQL Runner, Data Inspector), proxy (HTTP Server, S3 Relay, Address-Style Detector, Range Gateway, Zip Streamer, Config Service, Secret-Redaction Middleware), and shared types.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
PM-authored work breakdown: 7 epics (~30 tasks) with critical path, lead/size/dependencies per task, acceptance hooks mapped to spec success criteria, four-surface high-risk register (streaming byte-relay, streaming zip, cross-tab coordination, secret redaction), supply-chain checklist (duckdb-wasm 1.32.0 pin gate).

Scope per confirmed principal decisions: /config exposes all knobs in v1.0; ADR #02 governs over the spec 'temp storage' wording (analyst patching specs separately); connection profiles moved v1.0 -> v1.1.

Working artifact — deleted when v1.0 ships; slipped tasks move to docs/plans/v1.1.md, not struck through in place.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Monorepo skeleton with packages/{shared,proxy,web}, root pnpm workspace and single lockfile, strict NodeNext TypeScript base config, ESLint 10 flat config + Prettier, and Node 24.19.0 engine pin. Workspace cross-imports verified; pnpm -r build, typecheck, and lint are green. Framework-specific dependencies are intentionally deferred to their own tasks (V1/V5/F2/F5).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Zod 4 schemas plus inferred TypeScript types for all 14 relay endpoints (session/probe, config, s3 list/head/range, object delete/copy/folder-create, multipart init/part/complete/abort, single + bulk-zip download), with a closed RelayError code enum. Every object schema is .strict() so malformed payloads are rejected identically by proxy and web (96 vitest tests). Credential transport is pinned to custom request headers (x-s3-endpoint, x-s3-access-key, x-s3-secret-key, x-s3-address-style) exported as constants, because the multipart-part, download, zip, and range endpoints stream their bodies; F4's redact rules mirror these names. exactOptionalPropertyTypes kept. Review follow-ups: the /s3/range regex now requires the suffix dash, and the config validator was renamed to a unit-agnostic positiveInt.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Tailwind v4 CSS-first config in packages/web/src/styles.css: @theme inline maps the 11 semantic colors to runtime CSS variables from design-system/tokens.css so every utility emits var(--token) and tracks [data-theme] in one cascade; plain @theme covers fonts and radius (bare --radius drives .rounded). design-system/tokens.css is consumed in place via an @design-system Vite alias (never copied). Stands up the web build: Vite 8.2.0 + React 19.2.8 + @tailwindcss/vite, an additive bundler/DOM/jsx tsconfig override over the F1 base, and a proof page with a theme toggle. ADR #06 v3 fallback was not needed. V5 handoff: self-host JetBrains Mono, add React ESLint plugins + dev-server proxy + router/layout, replace the theme-toggle placeholder with the U1 Zustand store.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Logger-config factory redactLoggerOptions() spread into Fastify({ logger }) at boot — not a runtime plugin, because pino redact is a constructor option that cannot be retrofitted. Redact paths are derived from CREDENTIAL_HEADER_NAMES imported from @s3-vedrfolnir/shared (9 bracket-notation paths per header: req/err.req/res/err.res bodies+headers, bare headers, top-level, and req.query), so the contract and the redaction cannot drift. Adversarial negative suite (15 tests, including a non-vacuous control, per-header coverage, and a drift-guard). Verified empirically that F4 is defense-in-depth: Fastify 5.11.2's default req serializer omits headers and its default err serializer does not embed req, settled by reading the installed source plus positive-assertion tests that fail loudly if an upgrade flips either. Harness exported for H3 reuse against real endpoints. COMPONENTS.md still calls the interface a Fastify plugin; that doc update is architect-owned and tracked separately.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
createProxyApp factory constructs the Fastify instance with F4's redact config assigned to logger.redact at boot (the assign form, not the spread — pino reads options.redact, so spreading would silently disable redaction; the misleading sample in redact.ts is corrected here too). Redact is a security invariant: callers cannot disable it. The factory accepts an optional { level, stream } logger override so H3 can point F4's CaptureStream harness at the real app's routes. Registers /health and the /session, /s3, /config route-group plugins (handlers stubbed 501, populated by V2/V3/F6/T-series). Graceful shutdown via manual process.on (no new dependency). 9 tests cover boot, the logger-override seam, redact-active-on-real-app, the route stubs, and shutdown.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
react-router 8 routing skeleton (/login placeholder, / and /:bucket/* behind a requireSession loader stub that V6 swaps for the real session gate, plus a dev-only /dev/error route that is tree-shaken out of production). AppLayout renders the shell slots (top bar with breadcrumbs, the list Outlet, an inspector right-rail, and an upload-drawer overlay) with a responsive grid that collapses on mobile. A top-level React ErrorBoundary wraps the router and every protected route declares a branded errorElement, so a thrown render never produces a white screen (verified via /dev/error). The Vite dev server proxies /s3, /session, and /config to the Fastify proxy origin (env-overridable, default http://localhost:8080). JetBrains Mono is self-hosted as woff2 (air-gapped per the design system) replacing the Google Fonts CDN link. React ESLint plugins are scoped to packages/web/** so proxy/shared keep the bare TS ruleset. The F5 proof page is replaced; the Tailwind v4 token system is intact.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
V2 — relay-client abstraction in packages/proxy/src/relay: createRelayS3Client builds a fresh S3Client per request (multi-user stateless invariant; credentials enter only via extractCredentials from the header tuple, never a module-level cache). GET /s3/list runs ListObjectsV2 with a / delimiter so common prefixes fold into prefixes[] (logical folders), MaxKeys 100, and continuation-token round-trip; GET /s3/head runs HeadObject. Outgoing bodies are validated against the shared schemas. mapS3Error translates upstream S3/SDK/network errors onto the closed RelayError code enum (duck-typed on name//errno; never reads , so no credential leakage), and addressStyle maps to the SDK forcePathStyle option (ADR #09 — V2 consumes the cached value, V3 detects it). 24 tests including a per-request signing-context invariant. F6 — buildConfig reads the seven deployment knobs from env (human size and duration strings, fail-fast on malformed), validates against ConfigResponseSchema, and serves the singleton at GET /config with all knobs exposed (Q1); SQL_QUERY_LIMIT_SIZE stays at the spec 2 GB default (ADR #11 1 GB tuning is v1.1). 69 tests. Also: pnpm auto-appended a version-exact minimumReleaseAgeExclude for the pinned @aws-sdk family to pnpm-workspace.yaml — minimal and correct, but no minimumReleaseAge threshold is currently set anywhere (repo or host), so the exclude is inert; flagged to the architect as a supply-chain governance question.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
POST /session/probe validates credentials via ListBuckets and detects the endpoint's address style via a HEAD-bucket probe, virtual-host first (ADR #09). Resolved the address-style chicken-and-egg by extracting a lower-level createS3Client that takes forcePathStyle directly; createRelayS3Client now delegates to it (V2 API and per-request invariant preserved). Detection honors every ADR #09 sub-rule: success is a 2xx only (the SDK throws on non-2xx so the catch falls through), path-style is tried on any virtual-host non-2xx, and when both styles fail the response carries the path-style attempt's raw error (asserted with distinct sentinels), not the virtual-host one. A successful probe returns {ok:true, buckets, addressStyle} for the client to cache; the proxy does no further detection. S3-side failures (bad creds, network, both-styles-failed) surface as HTTP 200 carrying {ok:false, error} so the client branches on a uniform shape; missing credential headers return 400 with a bare RelayError. 17 tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Typed TanStack Query layer over the relay API. useS3List and useS3Head are keyed by (bucket, prefix, continuationToken) with each page a separate query (no auto-paging, per the navigation spec). The fetcher attaches the four credential headers via the shared CREDENTIAL_HEADERS constants, Zod-validates every 2xx body before returning (defense-in-depth alongside the proxy's own validation), surfaces a parsed RelayError on non-2xx, and threads the AbortSignal through for cancellation. Invalidation helpers (invalidateS3List/Bucket/Head/All) are ready for the later mutating tasks, and a cancellable useS3ListAll async-generator seam is in place for O1/O4 folder-delete enumeration. A CredentialProvider React context (defaulting to a throwing stub) is the seam V6 wires the real session store into. QueryClientProvider is mounted above the router; web now has a vitest test script. 37 tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Zustand session store implementing the Session Manager contract. login validates the credential tuple via POST /session/probe and persists the result; logout clears every store. The four credential values are held in a module-private cache deliberately kept OUT of the Zustand state object so React DevTools and console inspection can never surface the secret key; getCredentials returns a fresh tuple per call and getSession exposes only the non-secret view. Storage routes to sessionStorage by default and localStorage only with the explicit remember opt-in, each write clearing the other backend so toggles leave no stale blob. ADR #10 is enforced structurally: lastActivity is written only inside login and restoreOnLoad (the page-load refresh), with no touchLastActivity action exposed at all (asserted by an API-surface test plus a getCredentials-does-not-bump-lastActivity test). ADR #03 last-login-wins is realized by writing the persisted blob before broadcasting, with deterministic reconciliation on the receiver, and BOTH the BroadcastChannel('vedrfolnir') primary path and the storage-event fallback (when BroadcastChannel is unavailable) are under explicit test. Boot fetches GET /config, validates it, restores the session, and only then renders so there is no login flash, falling back to documented defaults if the config fetch fails. The CredentialProvider is wired so V7's fetcher reads live credentials at fetch time, and the real requireSession loader redirects unauthenticated navigation (a read-only timeout re-check on navigation; lastActivity is not mutated there). A boot-race guard (a review follow-up) prevents a cross-tab signal arriving during the config-fetch window from spuriously invalidating a valid remembered session. 90 tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Self-contained login form: endpoint URL, access key, a password-typed secret key, and a Remember-on-this-device toggle. Submit calls the session store's login action; a loading state disables the form while the probe is in flight. On success it navigates to /. On failure it renders a friendly headline mapped from the RelayError code plus the raw server message, always clears the secret field, and retains the endpoint and access key for retry. Submit is disabled until all three fields are filled. All styling uses the design-system tokens (light/dark via the data-theme swap); no hand-rolled colors. No connection-profiles quick-pick (out of v1.0, Q3). 7 tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Login -> ListBuckets -> navigate prefix -> paginate, end-to-end through web+shared+proxy. The bucket list is captured additively from the /session/probe response into the session store (no separate ListBuckets endpoint; V6's session tests stay green) and rendered by BucketsPage. BucketPage consumes a paginated, virtualized view: usePaginatedList mounts a fixed-ladder of useS3List hooks (MAX_PAGES literal bound, so the rules-of-hooks count is static and the suppression is justified), Load more threads the continuation token, and rows accumulate across pages. A generic ListView<RowT> virtualizes via TanStack Virtual (ADR #13) so 10000+ accumulated rows cost only the visible window's DOM nodes (verified by a real-library test: 10k fixture renders under 100 rows). Folders come from common prefixes plus zero-byte slash-terminated marker keys; a cross-page folder dedup (S3 repeats common prefixes on every page that contains them) is covered by regression tests. Breadcrumbs derive purely from the router location. Selecting an object row writes to a Zustand selection seam for the M3 inspector and issues NO metadata fetch (the only-free-metadata-while-browsing invariant holds: grep confirms no useS3Head or /s3/range in the browse path). useS3List gains a backward-compatible optional options arg (enabled/staleTime/gcTime/retry) for the fixed-ladder's enabled:false idle rungs. Loading, error, and empty states render. 52 tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Doc-sync from the V9 first-slice milestone. No decision changes — these reflect how the components were actually built.

COMPONENTS.md:
- Session Manager: drop 'connection profiles (nice-to-have)' from the responsibility (moved to v1.1 per ADR scope cut); add getBuckets() to the interface (V9 captures buckets[] from /session/probe so the bucket list doesn't re-fetch on mount).
- Address-Style Detector: expand the interface to the real shape — {ok:true, buckets[], addressStyle} | {ok:false, error} carrying the path-style attempt's raw error per ADR #09.
- Secret-Redaction Middleware: correct 'Fastify plugin' to 'logger-config factory' (pino redact is a constructor option, not a runtime plugin); note the shared-constants import that prevents drift; note Fastify 5's default serializers omit headers so the layer is defense-in-depth, not the sole barrier.

ARCHITECTURE.md:
- Security posture: align the 'secret never logged' wording with the factory mechanism + shared-constants discipline.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
auth-session.md says both 'sessionStorage by default' and 'opening a second tab shares the session', which are jointly unsatisfiable because sessionStorage is per-tab. V6 already resolves this correctly (cross-tab sharing is conditional on remember:true, which routes to localStorage). Adding a one-line comment at the cross-tab logic so a future reader isn't confused; the spec wording is being routed to the analyst separately by the architect.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Multi-stage Dockerfile (ADR #14): a node:24-slim builder does the full pnpm install and pnpm -r build, and a node:24-slim runtime does a prod-only frozen install and copies only the built shared/proxy dist. The image runs the Fastify proxy (node packages/proxy/dist/server.js) on PORT 8080, as non-root (uid 1000), with a HEALTHCHECK on /health. Verified: docker build succeeds, the container answers GET /health with {"status":"ok"}, and docker stop exits cleanly in ~200 ms via V1's SIGTERM handler. pnpm deploy (a leaner single-package bundle) was attempted but trips a pnpm Invalid-time-value bug in the release-age checker activated by the minimumReleaseAgeExclude config, so the runtime falls back to pnpm install --prod --frozen-lockfile; tightening the image is deferred to U5. .dockerignore keeps design-system/ (the web Vite build consumes it via the @design-system alias) and excludes node_modules/dist/.git/docs/tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
ci: workspace gate, supply-chain policy, and duckdb-wasm pin gate (F7)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m7s
de61625511
Lands the CI baseline before Epic 2 so the high-risk transfer work is gated from the first PR. Three deliverables. (1) minimumReleaseAge: 7d in pnpm-workspace.yaml, co-located with the existing version-exact @aws-sdk/*@3.1103.0 excludes so the threshold (default-deny) and the allow-list live together and self-justify each other; pnpm honors it from the workspace file, pnpm install --frozen-lockfile passes cleanly with no non-excluded package tripping the 7-day window, and the lockfile is unchanged. (2) A duckdb-wasm exact-pin gate (scripts/check-duckdb-pin.mjs, wired as pnpm check:duckdb-pin) that asserts @duckdb/duckdb-wasm, wherever it appears, is pinned to exactly 1.32.0 (ADR #08: the npm latest tag points to a dev build); the package is absent in v1.0 so the gate is armed for v1.1, and the detector is validated against eleven specifiers. (3) A Forgejo Actions workflow (.forgejo/workflows/ci.yml) running install, the duckdb-pin gate, typecheck, lint, build, and test on every push and pull request to dev and main. 382 tests green locally.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): streaming byte-relay data plane (T1)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m6s
ac5575913c
The data-plane foundation for the entire transfer surface (ADR #01). Two primitives in packages/proxy/src/relay/stream.ts: streamUploadToS3 pipes the incoming Fastify request body straight into an S3 PutObject/UploadPart command under sigv4, and streamDownloadFromS3 pipes an S3 GetObject response body straight to the Fastify reply via node:stream/promises.pipeline. Neither buffers a whole object — the upload Body IS the request stream and the download is pipeline-only, so proxy heap stays bounded by stream high-water marks regardless of object size. The UNSIGNED-PAYLOAD no-buffering-via-signing property (the crux of streaming uploads without materializing the body for a sigv4 hash) is locked in as a tested invariant: a custom capturing requestHandler proves the outgoing x-amz-content-sha256 is a streaming marker, not a 64-hex SHA, so a future SDK upgrade that materializes the body fails the test. Backpressure is pipeline's. Abort propagates both directions across every timing window — a client disconnect fires AbortController (pre-send) and pipeline-destroys the source (mid-stream); an S3 mid-stream error destroys the reply so the client sees a truncated stream, never a 200-complete on partial content. Every failure routes through mapS3Error (cred-safe). The F4 redact negative-test sweep is extended to T1's three streaming error paths (client disconnect mid-upload, S3 5xx mid-download, request-stream error mid-upload) plus a non-vacuous control. Dedicated adversarial review approved it; the upload-source JSDoc now steers callers to request.body (not request.raw, which Fastify's parser has already drained) so T5 won't hang. 163 proxy tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plans): mark T1 done in v1.0 plan
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 45s
893f0facb9
Annotated in-place per the plan's lifecycle policy: status (shipped + dedicated review APPROVED), the tested invariants (UNSIGNED-PAYLOAD no-buffering-via-signing, pipeline backpressure, bidirectional abort across all timing windows), F4 redact sweep extension to T1's three streaming error paths, and the two engineer-level downstream flags (T2 range headers; T6 Body-consumption seam).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): range gateway endpoint (T2)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 38s
48b9f8fade
GET /s3/range forwards an S3 GetObject-with-Range and reports whether the backend honored it, so the metadata inspector (and duckdb-wasm in v1.1) can fall back to a capped full fetch when ranges are unsupported. Reuses T1's streamDownloadFromS3 via two opt-in fields (reflectUpstreamStatus, extraResponseHeaders) rather than duplicating the streaming/abort/cleanup machinery. A 206 response sets the range-supported sentinel to true and forwards Content-Range/Accept-Ranges (added to the primitive's forwarded-headers list); a 200 full-body response sets it to false. The F4 redact negative-test sweep is extended to /s3/range's error paths (416 not-satisfiable, S3 5xx mid-range-read, client disconnect mid-range-read) plus a non-vacuous control. This endpoint is the v1.0/v1.1 handoff point: the M2 inspector consumes it now, and duckdb-wasm reuses it unchanged in v1.1. 14 route tests; 182 proxy tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): transfer engine single-slot queue with cross-tab coordination (T3)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 39s
d9d31bc468
The client-side transfer orchestration brain that T4/T5/T6 plug concrete executors into. A Zustand store (module-level, outside React's render tree so the queue survives component unmounts) owns the single browser-wide transfer slot (ADR #04), the executor registry, the cross-tab bridge, the session-change subscription, and the pre-flight size checks. enqueueUpload/Download/BulkDownload return a started-or-refused result; refusal happens pre-flight (caps from getConfigOrDefault: upload per-file, download single or sum-of-sizes, <= boundary) BEFORE any S3 call, or when the slot is held locally or by another tab. The slot is coordinated over the SAME BroadcastChannel('vedrfolnir') as the session (ADR #03), with a storage-event fallback, both under explicit test. The architect's session-change-releases-slot invariant is wired via useSessionStore.subscribe on sessionView identity and verified across logout, timeout, login-as-someone-else, and cross-tab reconcile — an in-flight transfer is aborted the moment its credentials would go stale. The dedicated adversarial review caught a cross-tab split-brain (two tabs acquiring near-simultaneously both ended up holding the slot); fixed with last-write-wins by acquire-timestamp, mirroring V6's reconciliation — a foreign transfer-acquired that is newer than this tab's active job self-cancels with a 'superseded' reason, so the never-both-non-null invariant is enforced rather than merely documented. The hung-executor path is documented as a contract (no fixed watchdog timeout — a legitimate >5 GB multipart upload runs for hours); the unreachable activeAbort-null dispatch path throws loudly rather than masking the bug. 187 web tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
fix(web): per-file upload pre-flight filtering, partial success (T3 point-revision)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 38s
9c938fcd91
object-transfer.md is unambiguous: in a multi-file upload each file is checked independently and only the oversize ones are rejected. T3 was refusing the whole job when any file was oversize, which over-reached ADR #04 (that ADR governs slot concurrency, not intra-job atomicity). preflightUpload now returns a partition (validFiles, oversizeOffenders) instead of TransferError|null; enqueueUpload enqueues the valid files as the job (offenders surface as a warnings list on the job, not a refusal) when at least one file is valid, and refuses pre-flight only when every file is oversize. Only validFiles reach the executor descriptor, so no bytes move before pre-flight clears. Download and bulk-download pre-flight are unchanged (single-object = own size vs cap; bulk = sum vs cap, all-or-nothing). TransferJob gains an extensible warnings field. The spec was right as written; no spec patch needed. 193 web tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): single-object download endpoint (T4)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 38s
05211f9fd8
GET /s3/download forwards an S3 GetObject and streams the body straight to the client via T1's streamDownloadFromS3 primitive (default options: status 200, forwarded per-object headers, no Range, no extra headers). The size cap is NOT re-checked on the proxy: T3 (the client transfer engine) knows the object size from the prior LIST/HEAD response and refuses before calling, so the proxy never sees an oversize request from the T3-governed client — the proxy is a byte-relay (ADR #01), and a HEAD-then-GET on the proxy side would double latency/bandwidth to defend only against a caller bypassing T3, which is outside v1.0's BYO-credentials threat model. Mid-stream errors truncate the reply (never a fake 200-complete) once headers are flushed; pre-stream errors emit a clean RelayError. The F4 redact negative-test sweep is extended to /s3/download's error paths — bad query, missing creds, S3 403/404/5xx, client disconnect mid-download — plus a control, including a hardening case that injects the sentinel into a non-credential header to prove the route's logging discipline (never log raw headers) extends beyond the redact paths. The browser-side save-to-file executor and progress UI are T8's job. 201 proxy tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
The H1 integration test (real MinIO) found that every healthy multipart upload failed mid-flight with 502 upstream_error / AbortError. Root cause: streamUploadToS3 wired source.on('close', onAbort), but Node fires 'close' AFTER 'end' on normal stream completion too (the SDK reads the body to the wire, the Readable ends, then closes as Node tears down the finished stream — all before the S3 response arrives), so the in-flight send was aborted the instant the body finished flowing. The T1 unit mock resolved send without reading the body, so the race window never surfaced. Fix: track 'end' via a sourceEnded flag and treat a post-end 'close' as the normal completion it is (no abort); a pre-end 'close' remains the genuine mid-stream disconnect. The 'aborted' (IncomingMessage premature-close) and 'error' events stay UNCONDITIONAL abort triggers regardless of sourceEnded, so a real client disconnect cannot masquerade as normal end-of-stream — defense-in-depth holds. A regression test reproduces the race (the mock holds send pending across the source end+close and rejects on abort like the real SDK); demonstrated to fail on the old code. Real-disconnect still aborts (the existing test, whose source never ends, still passes).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Both T5 halves (proxy endpoints and the web upload executor) independently discovered that MultipartPartRequestSchema, MultipartCompleteRequestSchema, and MultipartAbortRequestSchema carried only uploadId, but the AWS SDK commands for those operations require Bucket and Key too. The proxy is stateless (ADR #01 / ADR #07) and cannot map an uploadId back to a (bucket, key), so the client must carry bucket and key alongside uploadId on every post-init multipart request — exactly as init already does. Adding bucket and key to the three post-init schemas (kept .strict()) is a forced contract-completeness fix, not an ADR deviation. Tests updated to require bucket/key and to reject requests missing either.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
The four multipart routes replace the V1 501 stubs. POST /s3/multipart/init runs CreateMultipartUpload and returns {uploadId}. PUT /s3/multipart/part streams the request body straight to S3 as an UploadPart via T1's streamUploadToS3 — a passthrough content-type parser scoped to application/octet-stream makes request.body the live Readable (not request.raw, which Fastify drains), and the handler forwards the inbound Content-Length onto the command so the aws-chunked streaming signer can derive x-amz-decoded-content-length (Bug A, found by H1 against real MinIO — the unit mocks never run the real signing stack). POST /s3/multipart/complete runs CompleteMultipartUpload and returns an ack. DELETE /s3/multipart/abort runs AbortMultipartUpload — the cancel-cleanup path the no-orphaned-parts guarantee depends on — and NEVER swallows a failure (a swallowed abort = orphaned billable parts). Per-request client, mapS3Error on every path, incoming+outgoing schema validation. The F4 redact negative-test sweep is extended to the multipart endpoints' error paths, including the architect-flagged abort-failure path, plus a non-vacuous control.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
The 'upload' TransferExecutor that drives the >5 GB path and owns the no-orphaned-parts guarantee on cancel. Per file: POST /s3/multipart/init, then a part loop that slices the File into >=5 MiB parts (auto-sized up to keep the part count <=10,000; concurrency 1 for v1.0 correctness), each PUT streamed through the proxy with a bounded idempotent per-part retry on transient failures (network / 5xx / upstream_error, never 4xx or user-cancel), then POST /s3/multipart/complete. Progress accumulates across parts and files. The cancel guarantee is airtight: the uploadId is tracked from the moment init resolves, and for ANY upload that started but did not complete, DELETE /s3/multipart/abort runs — with NO signal attached, so T3's already-aborted signal cannot cancel the cleanup request itself. Edges handled: cancel before init (no uploadId, nothing to abort), cancel after complete (abort skipped — AbortMultipartUpload on a completed upload errors on some backends). On complete-failure the executor aborts-then-surfaces (S3 leaves the upload open on complete-failure; aborting honors the no-orphans spirit) and chains the original error; if abort ALSO fails, an MultipartAbortError supersedes so the user sees the actionable 'possible orphaned parts' signal. The executor registers at boot via registerExecutor and runs inside T3's single browser-wide slot. All uploads use multipart (the inventory has no single-PUT route), so a small file is simply init + 1 part + complete. 36 new tests.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
test(proxy): multipart no-orphaned-parts integration test against MinIO (H1)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 56s
d748b04ef8
The most load-bearing acceptance criterion in v1.0, proven end-to-end. A docker-compose brings up a real MinIO and the F3 proxy image; the integration suite drives the proxy's multipart endpoints directly (node-side HTTP with the four credential headers) and asserts four scenarios, all green: init→abort clears the upload on S3; init→(parts uploaded via the proxy part endpoint)→abort clears the upload AND its parts (ListMultipartUploads empty, ListParts -> NoSuchUpload — the strongest no-orphaned signal); a credential failure during abort surfaces a non-2xx relay error rather than {ok:true}; and a full multi-part upload (5 parts, 21 MiB) through the proxy completes and re-downloads byte-identical (SHA-256 match via /s3/download, the real proxy path). The >5 GB capability uses the identical code path — part-size auto-sizing and the 10,000-part ceiling are unit-tested in T5-web, and H1 exercises the real-backend mechanics at multi-part size. The suite is hermetic (random bucket, full cleanup in afterAll including defensive abort of any in-progress uploads) and gated off the unit suite via a separate vitest integration config (pnpm test:integration) so the standard pnpm -r test gate is unaffected. H1 earned its keep: it caught Bug A (the part PUT not forwarding Content-Length, which the aws-sdk-client-mock unit tests cannot see because they stub send above the sigv4/aws-chunked stack) and Bug B (streamUploadToS3 aborting healthy uploads on normal end-of-stream) — both now fixed and regression-pinned.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): streaming bulk-zip download with no-corrupt-archive guarantee (T6 + H2)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 41s
56468fd560
The last byte-relay high-risk surface. POST /s3/download/zip streams an application/zip assembled on the fly from per-key S3 GETs via yazl, with NO temp file (ADR #02): each object body pipes straight through yazl's deflate chain to the reply, and only the central-directory entries are held in memory (O(file count)). First byte reaches the client the moment the first object is fetched. A third additive primitive openS3ObjectStream returns the raw SDK Body for per-entry piping (streamDownloadFromS3 consumes the Body internally, so it cannot feed per-entry zipping) and keeps the cred-safe abort/error discipline centralized. The no-corrupt-archive guarantee is structural: zip.end() (which emits the central directory + EOCD) is reachable ONLY on a clean loop completion — every failure path (client-cancel via reply close, a per-key GET reject, a body stream error mid-entry, a yazl structural error) fires a shared AbortController and destroys the yazl outputStream BEFORE any finalize, so the response truncates with no central directory and the client never sees a 200-complete with a half-written central directory. On cancel, in-flight GETs abort and the yazl stream tears down with nothing to clean up (no temp file is the whole point of ADR #02). Pre-flight cap trusts the client (T3 enforces OBJECT_DOWNLOAD_LIMIT_SIZE from known LIST/HEAD sizes, like T4). The F4 redact negative-test sweep covers the new error paths including the mid-zip-failure (a leaked cred plus a swallowed failure would silently deliver a corrupt archive). H2 proves the guarantee end-to-end against real MinIO: a missing key mid-list yields partial bytes that yauzl rejects as 'End of central directory record not found... truncated' (parser-level proof, with byte-signature scan as supporting evidence); the happy path round-trips a 4-object zip whose entries are byte-identical (yauzl decompress + CRC); and a client cancel mid-zip leaves the proxy healthy. yazl 3.3.1 and yauzl 3.4.0 pinned. Dedicated adversarial review approved; no ADR #01/#02 deviation.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Implements the browser-side 'download' and 'bulk-download' TransferExecutors
(the proxy endpoints T4/T6 shipped; only 'upload' was registered until now).

- download-fetcher.ts: fetchDownload (GET /s3/download) + fetchBulkDownloadZip
  (POST /s3/download/zip). Creds in the 4 CREDENTIAL_HEADERS only, never in
  query/body; signal-threaded; non-2xx -> RelayHttpError. Returns the live
  Response (executor pipes the body, never .json()).
- download-executor.ts: createDownloadExecutor + createBulkDownloadExecutor +
  register* boot helpers, mirroring upload-executor.ts. Streaming-to-disk via
  the File System Access API is the primary path (body.pipeThrough(progress)
  .pipeTo(writer, {signal}) - zero full-body buffering, ADR #01/#02 mirrored
  browser-side). Blob fallback (<a download>) only when FSA is unavailable
  AND total <= FALLBACK_MAX_BYTES (256 MiB); over-cap throws a clear error
  rather than OOM the tab. Cancel: signal aborts fetch + writer + source.
  No 'completed on truncated body' path - mid-stream read errors reject
  verbatim -> T3 records 'failed'.
- fsa.d.ts: ambient window.showSaveFilePicker? (TS 6.0 lib.dom.d.ts ships the
  handle types but not the picker entry point).
- main.tsx: registerDownloadExecutor() + registerBulkDownloadExecutor() in boot().
- transfer/index.ts: barrel re-exports mirroring the upload block.

Not a high-risk surface (proxy streaming done + reviewed; this is browser
plumbing). 46 tests (35 dev + 11 tester-added adversarial). Reviewed: all
load-bearing checks PASS, no ADR deviation.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Completes the transfer UI surface over the T3/T5/T8a orchestration.

T7 - Upload UI (fe):
- components/transfer/TransferPanel.tsx: reusable, descriptor-kind-agnostic
  progress/cancel panel (subscribes to activeJob; kind-aware label + percent
  bar + cancel + warnings + terminal error by discriminated kind). Reused
  verbatim by T8b downloads.
- components/transfer/upload-target-store.ts: Zustand cross-slot store
  (mirrors selection-store): writer in BucketPage, reader in AppLayout.
- UploadDrawer.tsx (V5 placeholder) -> real slide-in dialog: drop zone +
  file picker (multiple), advanced-options collapsible (content-type,
  storage-class <datalist>, metadata key/value rows), role=dialog +
  aria-modal, Escape/scrim dismiss. Upload button in SectionHeader
  (reachable from root + nested prefixes). Key = prefix + file.name
  (empty prefix at root -> bare name, no leading slash).
  exactOptionalPropertyTypes honored (empty options -> {}, never
  {contentType: undefined}).

T8b - Download UI (fe):
- components/transfer/DownloadDialog.tsx + download-dialog-store.ts: modal
  disclosure dialog (switches on bulk vs folder; the latter mounts
  useS3ListAll with a 'Counting objects...' state, effect-driven not
  render-driven, cancellable on unmount).
- selection-store.ts: extended ADDITIVELY with selectedKeys Map +
  toggleSelected/clearSelected; the V9 single-select selection/select/clear
  seam is byte-for-byte unchanged (M3 unaffected, 5 original V9 tests
  preserved). Multi-select = separate gestures (click=inspector, checkbox=bulk).
- BucketPage.tsx: per-row checkbox + download icon, per-folder download-as-zip
  icon, bulk-action bar in SectionHeader. Folder-zip filters zero-byte /
  folder markers from the archive.
- ADR #02 disclosure copy (load-bearing, Q2): rendered text asserts 'in
  memory', 'bounded by file count', 'never writes anything to disk' and is
  canary-locked to NOT contain the substring 'temp' (catches drift to
  'temp storage'/'temp file'). Pre-flight refusal rendered BEFORE any fetch
  (e2e test asserts fetch never invoked on oversize).
- styles.css: @keyframes slidein (the drawer's motion-safe animation was a
  no-op without it).

Tests: 30 (T7) + 37 (T8b) + 8 tester-added adversarial. Reviewer-approved
(APPROVED WITH NITS); nits deferred to U2/U3 (dialog focus-trap/restore,
bulk-action-bar responsive wrap) - noted in docs/plans/v1.0.md.

docs/plans/v1.0.md: T7 + T8 flipped to DONE with implementation notes.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Replaces the last three 501 stubs in the /s3 route group with real handlers.

- POST /s3/object/delete -> DeleteObjectCommand({Bucket, Key})
- POST /s3/object/copy   -> CopyObjectCommand (same-bucket only; schema
  structurally forbids cross-bucket; CopySource = `${bucket}/${sourceKey}`
  raw, no proxy-side pre-encoding)
- POST /s3/folder/create  -> PutObjectCommand zero-byte marker
  ({Bucket, Key: prefix, Body:'', ContentLength:0}, no ContentType - folder
  detection stays size===0 && key.endsWith('/'))

Each handler mirrors V2/T5/T6: extractCredentials -> per-request client
-> parseXBody (.strict() shared schema, throws bad_request) -> send ->
AckResponseSchema defense-in-depth -> reply; catch -> sendRelayError
(mapS3Error, cred-safe). Stateless, multi-user safe.

F4 redact sweep extended (redact-object-ops.test.ts): 3 endpoints x
bad-body/missing-creds/mapped-403 + non-vacuous control.

Also drops the now-orphaned not-implemented.ts (sendNotImplemented had
zero consumers after O1) and refreshes two stale '501 stubs' comments
per the living-doc rule.

Decisions (adjudicated): copy-to-self left un-rejected at the proxy (a
UI guard for O4, not a wire concern); CopySource percent-encoding deferred
to H5 (aws-sdk-client-mock stubs the signing middleware; only a real
backend validates - same gap as T5's ContentLength). Folder-delete count
enumeration reuses V7's useS3ListAll on the web (NOT a new proxy endpoint
- packages/shared/src/s3-object-ops.ts is explicit).

44 route tests (40 dev + 4 tester-added). Reviewer-approved (APPROVED
WITH NITS, nits fixed).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
The web data-client layer for object operations. Consumes the O1 proxy
endpoints via the shared contract; produces the hooks/orchestrator O4's UI
will consume.

O2 - object-op mutations + cache invalidation:
- object-op-fetcher.ts: 3 POST fetchers (fetchDeleteObject/fetchCopyObject/
  fetchCreateFolder) sharing a postForAck helper. Creds in the 4
  CREDENTIAL_HEADERS only; 2xx -> AckResponseSchema.safeParse (defense-in-
  depth); non-2xx -> RelayHttpError. Mirrors V7/T5.
- use-s3-object-ops.ts: useDeleteObject/useCopyObject/useCreateFolder
  useMutation hooks; creds read at mutation time (latest session wins);
  invalidation in onSuccess.
- Invalidation scope: parent prefix + bucket-wide (the ancestor-refold
  hammer - V9's merged common-prefix view can refold on any mutation);
  copy also invalidates source-parent + dest-parent.
- 47 tests (33 dev + 14 tester-added).

O3 - rename/move (copy-then-delete):
- use-rename-move.ts: dedicated fetcher-level orchestrator (not nested
  useMutation) composing fetchCopyObject + fetchDeleteObject with phase-
  based progress (idle -> copying -> deleting -> completed | failed |
  cancelled | cancelled-after-copy). S3 CopyObject is opaque to byte
  progress, so phases are the honest v1 signal.
- Structural 'source intact on interruption' guarantee (the acceptance
  criterion): the delete call is reachable ONLY from inside the copy's
  success continuation - no finally, no optimistic schedule; covered across
  4 exception classes (AbortError/RelayHttpError/TypeError/RelaySchemaError).
- ADR #04 honored: rename is NOT transfer-slot-gated (CopyObject/
  DeleteObject move bytes backend-side).
- Honest partial-rename surfacing: cancel-after-copy + delete-failure-after-
  copy both surface targetCreated=true so O4 can tell the user the target
  exists though the source was not removed.
- Re-entrancy race fixed (tester repro + reviewer): identity guard on all
  terminal/cleanup state writes so a late cleanup from trigger A can't
  clobber trigger B's controllerRef (was no-op'ing B's cancel() + a phase
  flicker). 3x stable.
- Same-key guard (PM-adjudicated): rename with sourceKey === destinationKey
  is DESTRUCTIVE (self-copy then delete erases the object - NOT symmetric
  with O1's benign copy-to-self); rejected pre-flight with a typed 'failed'
  outcome naming the consequence. O4 will additionally disable its confirm
  button (defense-in-depth).

Review nits fixed: test observers kept mounted across invalidation
assertions (removed the gcTime-macrotask race); isIdle/status forward-
guidance JSDoc for O4; doc-drift in invalidate.ts/query-keys.ts;
isAbortError dropped from the public barrel (internal helper).

68 tests across the two modules. Reviewer-approved (APPROVED WITH NITS,
nits fixed).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): O4 operations UI (delete/copy/rename/create-folder + confirmations)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 48s
111cd577df
The user-facing surface for object housekeeping, consuming O1/O2/O3.

- components/object-ops/ObjectOpsDialog.tsx + object-ops-dialog-store.ts:
  one unified dialog (mounted in AppLayout) discriminating on kind
  (delete-object/delete-folder/rename/copy/create-folder). Mirrors the
  T7/T8b cross-slot Zustand pattern + dialog a11y baseline.
- BucketPage: per-row RowActionsMenu (Rename/Copy/Delete on objects,
  Delete-folder on folders) + 'New folder' button in SectionHeader,
  alongside T8b's existing download icon + bulk checkbox.
- Destructive-op confirmation = an 'I understand...' checkbox gating the
  confirm button (the explicit-confirmation gesture, per object-operations.md).
- Delete-folder: mounts useS3ListAll({autoStart}) -> 'Counting objects...'
  -> 'N objects will be permanently deleted' -> confirmable; cancellable
  during enumeration and during the delete loop.
- Rename: drives O3 useRenameMove with phase progress; source===dest
  disables confirm (defense-in-depth alongside O3's data-layer guard);
  honest partial-rename copy for cancelled-after-copy AND
  failed+targetCreated:true (the latter was a load-bearing bug caught by
  tester+reviewer - the terminalCopy switch now branches on
  isFailed && targetCreated; spec NFR 'communicate honestly').
- Copy: overwrite warning when destination matches an existing key.
- Create-folder: appends '/', rejects multi-segment names.
- a11y baseline matches T7/T8b (role=dialog, aria-modal, Escape, scrim,
  focus-visible rings); focus-trap/restore deferred to U2/U3 uniform sweep.

471 web tests. Reviewer-approved (CHANGES REQUESTED on the partial-rename
honesty gap -> fixed -> APPROVED).

KNOWN LIMITATION (inherited, surfaced as a spec/architecture tension for
the architect, NOT an O4 bug): folder-delete count + delete cover only
DIRECT children because /s3/list uses a '/' delimiter (folds nested keys
into common-prefixes). object-operations.md defines folder-delete as
recursive; fixing the depth needs a proxy-side non-delimited LIST mode
(shared S3ListRequestSchema change) or client-side recursive walking.
Documented in O4 JSDoc; does not block O4's UI correctness for flat folders.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Two items routed from the architect queue; the ADRs/code governed in
the meantime — these bring the specs in line with the decided behaviour.

auth-session.md: 'all open tabs share one session' was jointly
unsatisfiable with sessionStorage-by-default (per-tab). Split the
single-session invariant (always, client-enforced per ADR #03) from
literal cross-tab sharing (requires remember:true -> localStorage).
Matches the PM's resolution in session/cross-tab.ts; success criterion
updated to match.

object-transfer.md / backend-proxy.md: 'zip temp storage' wording
predated ADR #02 (in-memory central directory, no temp file). Replaced
all occurrences: streamed in-memory assembly, memory bounded by file
count, nothing written to disk. README index blurb updated to match.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(specs): add internationalization capability spec (v2)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 48s
42d93ac382
i18n was previously only a 'deferred' line in ui-shell.md. Promote it
to a full v2 capability spec (i18n.md) so the what is defined before
work begins and v1 authors know it is a planned, not abandoned, concern.

Covers: translatable surface (visible labels, placeholders, tooltips,
aria text, and dynamically-generated error/progress strings); externalized
message catalog editable without code changes; locale selection with
operator-configurable default; live switching without reload/re-login;
interpolation + pluralization; locale-aware date/number/size formatting;
graceful English fallback (no blank/crash, gaps detectable); full
v1.0 + v1.1 coverage; ships >=1 non-English locale to prove the pipeline.
NFRs stated as constraints (lazy locale loading, layout length-tolerance,
a11y preserved, no secrets in the translation layer); the library/format
choice is left to the architect. ui-shell.md scope boundary and the README
index updated to reference it.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(s3-list): add recursive flag to /s3/list (O5)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 44s
a21961fba6
Folder-delete is recursive per object-operations.md, but /s3/list used a '/' delimiter so it returned only direct children — O4's enumeration undercounted and orphaned nested objects.

Add optional recursive: boolean (default false) to S3ListRequestSchema. When true, the proxy omits the delimiter so S3 returns all keys under the prefix natively (its default behavior). Folder-delete enumeration (enumerateS3ListAll/useS3ListAll) passes recursive: true; V9 browsing (useS3List/usePaginatedList) passes recursive: false explicitly — defensive, pinned at hook + V9-consumer test layers so the browsing-stays-flat invariant doesn't rely on the schema default.

S3ListRequest type switches z.infer -> z.input so the wire-request shape keeps recursive optional (reviewer-validated: first .default() field in shared). Proxy coerces canonical 'true'/'false' and rejects '1'/'yes'/'' as bad_request.

Additive, non-breaking, one round-trip. No ADR #01/#02 impact; F4 redact sweep unchanged (same LIST underneath, no new error surface). Stale O4 'shallow enumeration' docs corrected.

934 unit tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): range gateway client + bounded buffer (M1)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 50s
f2c71a13e2
fetchS3Range — the web client for GET /s3/range. Same-origin GET with the 4 credential headers + Accept: application/octet-stream; reads the range-supported sentinel via the shared RANGE_SUPPORTED_HEADER constant; non-2xx surfaces as the reused RelayHttpError. Returns {bytes, rangeSupported, contentRange?}.

OOM defense (the security-critical property): when rangeSupported === false, a non-range backend returns the FULL object body. A multi-GB object would blow the tab. A maxBytes cap (default 2 MB) guards it — pre-buffer content-length check cancels response.body and throws the typed RangeFallbackTooLargeError; a post-buffer check catches a lying/absent content-length. Honored 206 ranges buffer fully (the cap is fallback-only; the asymmetry is pinned by tests).

Reuses buildCredentialHeaders / parseRelayError / buildRelayUrl (additively exported from data/fetcher.ts) and RelayHttpError — no duplication. New packages/web/src/metadata/ module (range-fetcher + index barrel); foundation for M2/M3.

25 tests including adversarial OOM-boundary cases (cancel-then-no-buffer, lying content-length, no-content-length huge body, honored-range > cap buffers fully, header case-insensitivity, abort propagation). 959 unit tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): metadata extractors — image/parquet/text (M2a)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 47s
3d8fb65c39
The metadata-extraction skeleton + the three range-based format extractors, satisfying the spec success criteria (50 MB JPEG EXIF from header bytes; multi-GB Parquet schema/row-count from footer; unsupported format = clean state).

inspectMetadata({bucket,key,creds,signal,deadlineMs?}) dispatches by file extension, runs the matching extractor under a wall-clock deadline (AbortSignal.any([caller, timeout]) + Promise.race), and returns a normalized MetadataResult = {kind:'loaded',metadata:{format,fields,notice?}} | {kind:'unsupported'} | {kind:'error',message}. Extractors receive an injected readRange fn (no raw creds — credential isolation) built on M1's fetchS3Range; all parsing is wrapped in try/catch and degrades to a graceful result, never throwing into the UI.

Range-based: image (exifr — EXIF/IPTC/XMP + dimensions, 512 KiB header; HEIC best-effort), parquet (hyparquet — schema/row-count/row-groups from a 1 MiB footer suffix), text/code (jschardet charset + line count + size-capped preview, 128 KiB prefix). PDF + MS Office are M2b (capped full fetch).

New deps (exact pins): exifr@7.1.3, hyparquet@1.26.2, jschardet@3.1.4. Supply-chain gate green; minimumReleaseAge policy intact; duckdb-pin gate unaffected.

Dedicated adversarial review (F4/T1/T3 class) caught: a real bug — jschardet processed up to a 2 MB fallback body synchronously (~700 ms main-thread block on non-range backends, affecting normal large text files), fixed by capping the processed sample to 128 KiB (~50 ms now). Documented limitation: the deadline is event-loop-bound and cannot preempt sync parsers — hyparquet can spin ~300 ms on a crafted LIST<BOOL> footer (bounded, graceful, normal Parquets fast). Web-Worker decision deferred to the U3 responsiveness audit (re-open if M2b's pdf-lib shows non-adversarial cost). No ADR #01/#02 deviation (bytes only via the Range Gateway; in-memory buffers, no temp file).

1019 unit tests green including 30 adversarial cases (sync-parse timing budgets, deadline-cannot-interrupt-sync-spin proof, malformed EXIF/Parquet, shape invariants).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): metadata extractors — PDF + MS Office (M2b)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m27s
40c32c4fa7
Completes the metadata extractor set: PDF (pdf-lib) and MS Office OPC core-properties (fflate + DOMParser), wired into the M2a dispatch skeleton. Both use the capped full-fetch pattern (bytes=0-<CAP-1>, maxBytes=CAP) so M1's fallback defense applies on non-range backends.

PDF: PDFDocument.load({updateMetadata:false, ignoreEncryption, throwOnInvalidObject:false}) on a 1 MiB cap -> page count + Info fields (title/author/subject/creator/producer/created/modified). Real PDFs parse in ~3 ms (incl. 4800-page specimens).

MS Office (docx/docm/xlsx/xlsm/pptx/pptm): fflate unzipSync with a filter decompressing ONLY docProps/core.xml (decoy entries never inflated — proven: ~0.3 MiB heap delta on a 128 MiB decoy), pre-inflate originalSize guard, reactive post-inflate check; DOMParser 'application/xml' (XXE blocked via parsererror). Core properties -> title/author/created/modified/etc.

Dedicated adversarial review (F4/T1/T3 class): office zip-bomb defense verified 3-layer genuine; XXE safe. PDF decision — ship @1 MiB for v1.0 (reviewer + tester concur, consistent with the hyparquet precedent): adversarial %PDF-1.7+garbage freezes ~2.4-3.1 s (bounded, graceful, no zombie at the default 5 s deadline — no-zombie rests on CAP sizing, not the deadline, which is proven a no-op for pdf-lib's sync chunks). pdf.ts + office.ts docstrings corrected to state the limitations honestly. Two carry-forwards to the U3 responsiveness audit: (1) move extractors (pdf-lib + hyparquet) to a Web Worker so the deadline can actually interrupt; (2) streaming-inflate hardening for core.xml (a forged central directory can cause a transient inflate OOM — narrow threat, requires bucket write access).

New deps (exact pins): pdf-lib@1.17.1, fflate@0.8.3. Supply-chain gate green; minimumReleaseAge policy intact; duckdb-pin gate unaffected. No ADR #01/#02 deviation (bytes only via the Range Gateway; in-memory buffers bounded by caps, no temp file).

1067 unit tests green incl. adversarial timing pins + zip-bomb probes.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): metadata inspector panel (M3) + fix pdf-lib timing flake
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m5s
58bd1df121
Replaces the V5 Inspector placeholder with the real on-select metadata panel, completing Epic 4. useObjectMetadata(selection) drives it via TanStack Query (['metadata', bucket, key], enabled: !!selection, staleTime 60s) — free cancellation on deselect/navigate/unmount, stale-while-revalidate on re-select, ADR #12 consistency. The query signal threads into inspectMetadata -> fetchS3Range -> fetch.

6-state panel: idle (placeholder) / loading (role=status, aria-live) / loaded (format header + fields definition list + optional notice callout) / unsupported (calm, muted — NOT an alert) / error (role=alert, danger-toned). Reads the V9 useSelectionStore selection seam; BucketPage's navigate-clear() returns it to idle and aborts in-flight. Design-system tokens only; verified via an on-prem vision-model visual loop across all states.

Load-bearing invariants pinned (29 tests): V9 'never pre-fetched' (mount + paginate + prefix-navigation -> ZERO inspectMetadata calls; positive complement: select -> exactly 1); signal-threaded cancellation incl. rapid A->B->C no-stale-flash + stale-error-after-deselect no-flash; text-only security rendering (the M2a carry-forward — every MetadataField.value/notice/message is a hostile-endpoint string rendered as plain React text, never dangerouslySetInnerHTML/href; pinned incl. a javascript:/data-URI href-sink probe so a future <a href> regression fails loudly).

Also fixes the recurring M2b pdf-lib timing flake (surfaced 3x under parallel-suite contention): the A1/A2 adversarial-parse assertions now derive from DEFAULT_INSPECT_DEADLINE_MS - 250 (the no-zombie boundary, with headroom) instead of a too-tight magic 4000ms literal — asserts exactly what it claims (parse completes before the default 5s deadline) and tracks the constant if it changes.

1096 unit tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): record metadata-parser pins in tech-stack table
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m8s
55d69abdc8
Epic 4 (Metadata Inspector) introduced five browser-side parser libraries for the v1.0 inspector. Recorded with exact pins per the supply-chain gate (F7): exifr 7.1.3 (image EXIF/IPTC/XMP), hyparquet 1.26.2 (Parquet footer), pdf-lib 1.17.1 (PDF page count), fflate 0.8.3 (OPC zip inflate for MS Office core-properties), jschardet 3.1.4 (charset detection). No ADR (parsers are isolated/swappable per the trivial-library-pins rule).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): serve built web bundle same-origin (U4, ADR #14)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m1s
8afca815e8
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): persisted OS-default theme toggle (U1)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m19s
a34ffa5c58
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
test(proxy): adversarial static-serving + API-priority coverage (U4)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m7s
66e76d9ddc
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
20 adversarial cases pinning the U1 theme-toggle seams the happy-path
suites don't exercise: localStorage persistence isolation, 3-state
preference persistence, synchronous FOUC-avoidance via initTheme(),
OS-listener hygiene across re-inits, graceful degrade on a throwing
matchMedia, document-level OS-change reactivity, a11y DOM contracts,
and a design-system-only (no bespoke color literal) fence.

Verified green against the committed U1 feature (a34ffa5): 20/20 in
this file; full web suite 693/693; web typecheck clean.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plan): Epic 5 wave plan + U1/U4 done
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m0s
1f8a191955
Record the durable Epic 5 execution notes (wave sequencing, standing
rules) and mark Wave 1 complete:

- U1 (persisted OS-default theme toggle): feature a34ffa5 + test 793a326.
- U4 (proxy serves web bundle, ADR #14): feature 8afca81 + test 66e76d9.

U3 expanded S->M and reassigned fe->dev (load-bearing Worker retrofit;
dedicated adversarial review before merge). U2/U5 status markers set for
Waves 2-3.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): U5 PROXY_BASIC_AUTH middleware + Docker web-bundle finalization
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Has been cancelled
3b72942fee
Optional HTTP Basic Auth as a global onRequest hook (off by default; ADR #14
acceptance: docker run with no env is unauthenticated). /health is exempt
(Docker HEALTHCHECK). Constant-time credential compare via timingSafeEqual;
fail-fast ConfigParseError when a non-empty PROXY_BASIC_AUTH lacks a colon
(the raw value is redacted in the error message, not echoed). The
'authorization' header is added to EXTRA_REDACT_HEADER_NAMES and covered by
the full 9-path F4 redact config.

F4 redact sweep extended to the basic-auth error paths (standing rule):
redact-basic-auth.test.ts drives the real armed app through no-header /
malformed / wrong-credential 401 challenges, scanning both the configured
password and the incoming Authorization value, with a non-vacuous control.
basic-auth.test.ts adds focused helper coverage (timingSafeStringEqual
no-throw-on-length-mismatch, parse/decode matrices, buildBasicAuthHook).

Dockerfile: runtime now carries packages/web/dist (U4 same-origin serving);
node:24-slim base (ADR #07). pnpm deploy still blocked by the upstream
'Invalid time value' bug under pnpm 11.20.0 — web prod deps land in the
runtime node_modules (documented, benign).

docker build + container smoke-test green (no-env + armed, /health exempt,
exact realm, wrong/correct creds). Proxy suite: 467 tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): U3 metadata Web Worker + streaming-inflate bomb defense
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m6s
588735763b
Moves pdf-lib + hyparquet extraction off the main thread so worker.terminate()
can hard-preempt a mid-chunk parse — the structural fix for the ~2.6s pdf-lib
main-thread freeze (event-loop-bound Promise.race/setTimeout could not
interrupt it). Range I/O stays on the main thread (credentials never reach
the worker); the fetched bytes are transferred (zero-copy) to the worker,
which runs the parser and posts back a MetadataResult.

  - worker-protocol.ts: request/response message types.
  - worker-coordinator.ts: main-thread seam (injectable WorkerFactory);
    registers the abort listener before postMessage; transfers bytes.buffer;
    a single 'resolved' flag serializes every settle path (onmessage/onerror/
    abort) so no double-resolve, no missed cleanup; terminate() on every path.
  - metadata-worker.ts: the worker script (Vite bundles it as a separate chunk).
  - worker-parsers.ts: the pure parsePdf/parseParquet logic shared by BOTH the
    worker script and the in-process test factory (no duplication / no drift
    footgun). Tests therefore exercise the real worker parsers.

office.ts hardens the OPC core.xml path: replaces fflate unzipSync with a
streaming Unzip + UnzipInflate + UnzipPassThrough path and a HARD
decompressed-byte cap (CORE_XML_MAX_BYTES = 1 MiB) checked after each 32 KiB
chunk — closes the forged-central-directory transient-OOM hole (a lying
originalSize is aborted mid-stream; fflate@0.8.3 fires ondata synchronously
from push, confirmed against installed source). EOCD pre-validation keeps
garbage/non-ZIP distinguishable from a valid ZIP without core.xml.

inspect.ts: deadline Promise.race keeps the promise-settle guarantee; a new
guard maps a worker-path result to 'Cancelled' when the CALLER's signal
aborted mid-parse (so worker + non-worker paths are consistent).

Dedicated adversarial review pass complete (T3 class): coordination logic is
race-free (terminate-vs-resolve, late-message-after-abort, listener cleanup,
signal-already-aborted, no credential leak to the worker); streaming cap
verified against fflate source; forged-central-directory bomb test (§11.4)
proves Layer 3 fires to the calm notice without OOM/crash. Stale 'deferred
Worker retrofit' comments removed (now 'shipped'). Web suite: 713 tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): U2 responsive layout + dialog focus-trap sweep
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m4s
d5ee6cf3d6
Closes the deferred a11y/responsive nits carried from T7/T8b/O4 and records
the high-density-list delta surfaced by V9.

Dialog focus management (uniform across UploadDrawer, DownloadDialog,
ObjectOpsDialog): a dependency-free useDialogFocus hook moves focus into the
dialog on open (without overriding a child's autoFocus), traps Tab/Shift+Tab
within the live-computed tabbable set (wraps last->first / first->last,
skips tabindex -1 / disabled / aria-hidden / hidden / hidden-inputs /
contenteditable=false), handles Escape, and restores focus to the opener on
close (body fallback if unmounted). The three dialogs' per-dialog Escape
listeners are consolidated into the hook (no double-handling); role=dialog +
aria-modal=true + the tabIndex=-1 scrim are preserved. 19 hook tests + 3
component-level focus-in/restore tests.

Responsive: SectionHeader now groups bulk-actions vs primary-actions as
distinct shrink-0 wrapping units (was: flat button list that wrapped every
button independently on narrow widths). Mobile is graceful degradation (no
phone-specific fork, no new breakpoints, tokens only). Verified via a real
Playwright + on-prem-vision loop at desktop/tablet/phone widths (no horizontal
overflow at 390px; dialogs unclipped; focus-trap live behavior confirmed).

ui-shell.md: records the high-density-list affordance per the spec's deltas
clause — TanStack Virtual windowing (display: arbitrary loaded counts, ~tens
of DOM rows for a 10k fixture) vs the fixed-ladder load ceiling
(MAX_PAGES=100 x pageSize=100 ~= 10k loaded keys); no dedicated density
toggle in v1. Reviewer nit applied: display-capability vs load-ceiling stated
accurately.

Web suite: 736 tests green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plan): Epic 5 complete — U2/U3/U5 done, hard stop before Epic 6
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m3s
e9b21f973b
Marks U2 (d5ee6cf), U3 (5887357), U5 (3b72942) DONE with the session-3
execution trail (stabilize -> per-task test/review -> review-nit fixes ->
ship). Final totals: 1310 unit (107 shared + 467 proxy + 736 web) + 7
integration = 1317 green on origin/dev. Logs four deferred follow-ups
(ADR #14 arch-doc amend; RFC 7617 scheme case; Dockerfile web-deps;
pdf-lib timing-probe CI stability) and records the HARD STOP at the Epic 5
boundary — Epic 6 is principal-gated and untouched.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): reconcile PROXY_BASIC_AUTH as native (ADR #17, U5)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m4s
2707789260
U5 implemented PROXY_BASIC_AUTH natively as a Fastify onRequest hook
inside the proxy, reversing the placement #14 attributed to the reverse
proxy. ARCHITECTURE.md deployment-view diagram + prose now show auth
inside the image; the reverse proxy's remaining job is TLS. ADR #14
annotated (not superseded — its core single-image decision stands); new
ADR #17 records the native implementation with its security properties.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): Epic 6 design — ADR #18 Playwright E2E + test strategy
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m7s
aded628c6c
H5 needs browser-driven E2E tests against MinIO + AWS S3. No E2E
framework existed in the project. ADR #18 records the decision to adopt
Playwright 1.62.1 (Chromium-only, two suites: MinIO CI-eligible + AWS S3
manual/gated). ARCHITECTURE.md gains a Test strategy section (unit /
integration / E2E layers) and the tech-stack table row. COMPONENTS.md
gains the E2E Test Layer component.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Extends the F4 secret-redaction negative suite to the three endpoint
error paths not yet covered (12 new tests across 3 files). Each drives
the REAL proxy route through the redact-configured Fastify, injects
FIXTURE_SECRET into credential headers, triggers the error path, and
asserts zero leaked bytes — plus a non-vacuous control proving the
harness detects a real leak when redaction is absent.

F4 backlog defense-in-depth items addressed:
- Added alias-shape redact paths raw.headers["N"] + request.headers["N"]
  (PATHS_PER_HEADER 9 -> 11); drift-guard + bracket-coverage extended.
- Characterization test pinning the rawHeaders array as the structurally
  un-redatable surface (defended by convention + lint, not suppression).
- ESLint no-restricted-syntax rule flagging string concat/template-literal
  as a structured-logger message arg (credential interpolation bypasses
  pino path-based redact).

Proxy unit count: 467 -> 483 (+16 tests, +3 files).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
H4: cross-tab coordination component-level tests (last-login-wins, transfer conflict, storage-event fallback)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m20s
364928c60a
Verifies the ADR #03/#04 cross-tab contracts manifest at the React
COMPONENT level, not just the store level (which the existing
session-cross-tab.test.ts / transfer-cross-tab.test.ts already cover).

5 tests across 3 scenarios:
(a) session-changed broadcast -> requireSession loader re-evaluates ->
    loser tab redirects to /login (asserted on rendered LOGIN_MARKER UI)
(b) transfer-acquired broadcast -> real BucketPage DownloadErrorBanner
    surfaces role=alert 'Another tab is busy' (executor never called)
(c) storage-event fallback: same UI behavior with BroadcastChannel
    stubbed to undefined (unsetBroadcastChannel + dispatchStorageEvent)

Renders real production components (requireSession loader, BucketPage)
through createMemoryRouter + RouterProvider inside the full provider stack.
Web unit count: 736 -> 741 (+5 tests).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
H5: Playwright E2E suite — MinIO happy path, multi-user, AWS S3 (ADR #18)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m5s
6e33bfbf4a
Browser-driven end-to-end tests proving the full product flow works:
login -> browse -> upload (small + multipart) -> download (single + zip)
-> metadata inspect -> object ops (copy/rename/folder/delete) ->
session-clear, against a real S3-compatible backend.

14 E2E tests pass, 5 skip (AWS env-gated):
- minio-happy-path.spec.ts (13): full sequential walkthrough through the
  real SPA, driving Playwright page interactions against the proxy-served
  web bundle (U4). Byte-identity verified via SHA-256; zip entries
  verified via yauzl.
- minio-multi-user.spec.ts (1): two browser contexts, independent sessions,
  independent navigation, independent key spaces. Same root creds (per-req
  isolation unit-tested separately; different-creds deferred to v1.1).
- aws-s3-happy-path.spec.ts (5): env-gated behind VEDRFOLNIR_E2E_AWS_*,
  skips silently when unset. Serial + shared-page pattern.

Framework: @playwright/test 1.62.1 (Chromium only, workers:1).
docker-compose.e2e.yml reuses the H1 topology (MinIO + proxy image
serving the web bundle). typecheck:e2e added to the root typecheck gate.

Review-approved after fixing: dead storage assertion (uncovered a real
storage-routing bug), honest same-creds deferral documented in ADR #18,
AWS spec serial+shared-page structure, COMPONENTS.md helper-copy note.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plan): Epic 6 complete — H3/H4/H5 done, v1.0 feature-complete
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m27s
7a40c1da9d
Folds the PM's execution-tracker status into v1.0.md (H3/H4/H5 ✅ DONE
markers + outcome notes, matching the H1/H2 pattern). Removes the
temporary epic-6-hardening.md execution plan — its content is now
authoritative in v1.0.md. All seven Epic 6 tasks (H1–H5) are closed.

Final test totals: 1331 unit + 7 integration + 14 E2E (5 AWS skipped) =
1352 active tests, all green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: add AGENTS.md — project guide for AI agents
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m23s
2d17f024df
Navigation map for agents working in this repo: repository layout,
documentation hierarchy (specs → architecture → plans), agent role
boundaries, build/test commands, ADR process, supply-chain hygiene,
type-safety conventions, secrets handling, and the load-bearing
gotchas (stateless proxy, Web Worker coordination, cross-tab session,
streamed zip). Companion to ARCHITECTURE.md — aimed at agents, not
humans.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: add README.md and CONTRIBUTING.md
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m5s
591b50e8d2
Release-blocking end-user and contributor docs. README covers the product overview, single-image build/run (ADR #14), BYO-credentials security model, optional PROXY_BASIC_AUTH (ADR #17), and the operator config knobs. CONTRIBUTING covers dev setup, the three test layers, the F4 redact standing rule, ADR process, and commit conventions. No LICENSE file exists yet (flagged to principal).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): align unit test count with AGENTS.md (~1330)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Failing after 1m5s
3f09919b28
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Drop all 23 wall-clock upper-bound assertions on CPU-bound parser/extractor calls in metadata-adversarial.test.ts (hyparquet, jschardet, exifr, parquet, text, pdf-lib, fflate/Office) and the 4 flaking A-section ceilings in m2b-adversarial-review.test.ts. Per ADR #19 these ceilings are not load-bearing (CI-flaky under parallel-suite contention); the no-zombie guarantee rests on worker.terminate() (worker-offloaded parsers) and CAP constants (main-thread bounded extractors), pinned deterministically in metadata-worker-coordinator.test.ts and via the CAP anchors here.

This is the class-wide application of the rule, NOT a one-off ratchet of the single flaking jschardet assertion (the 4th m2b recurrence): every section's upper bound is converted in one atomic pass so the gate-red root cause is removed, not just the symptom that fired today. Lower bounds (library-cost regression catch), result/throw-shape guards, and the two byte-size sanity assertions are retained. Deterministic CAP anchors added: TEXT_PREFIX_CAP via source-text regex (§2, constant not exported so source-text read mirrors m2b §B3) and the parquet footer range via readRange spy (§4 — placed in the extractor test, not the direct §1 hyparquet test, because §1 calls parquetMetadata directly with no readRange to intercept).

Includes the architect's ADR #19 docs (DECISIONS/ARCHITECTURE/COMPONENTS) and the v1.0 plan annotation, landed atomically with the fix they justify.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(spec): add operator-controlled offered locales to i18n (v2)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m12s
54db0d7cf6
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
object-transfer.md adds the stage->review->confirm flow (drop/pick stages into a review list; an explicit Upload action commits the batch; closing the drawer discards staged files) and per-batch advanced options. s3-navigation.md requires the listing to reflect completed mutations (upload/delete/rename/copy) without a manual refresh.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
ADR #20 records the list-alignment fix: a per-page CSS-grid template (OBJECT_GRID_CLASS / BUCKET_GRID_CLASS) shared by header and rows so columns track the same tracks, with the header made sticky inside the scroll container (no scrollbar-gutter needed). COMPONENTS.md List View entry updated to match.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
releaseSlot now invalidates the target (bucket, prefix) LIST queries plus the bucket-wide listing when an upload job completes (guard: terminal==='completed' && kind==='upload'), matching the use-s3-object-ops discipline. Fire-and-forget with a lazy getQueryClient() so the transfer engine stays import-safe; no invalidation on cancel/failure (no orphan masking) or downloads. Makes the s3-navigation 'listing reflects mutations' requirement hold for uploads. Tests cover completed-upload positive, cancel/fail/download negatives, and multi-file invalidates-once.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
UploadDrawer now stages dropped/picked files into a review list (accumulates, per-row removable) instead of uploading immediately; an explicit footer Upload action commits the batch, advanced options apply per-batch, and closing the drawer discards staged files. Handlers snapshot the FileList/DataTransfer synchronously before deferring the setStaged merge (live FileList is emptied by value=''; jsdom cannot catch this, so it is E2E-guarded). Tests rewritten to the staged flow plus new staging regression guards; the Playwright uploadFile helper clicks the new commit button.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
ObjectListHeader/ObjectRow/FolderRow share one OBJECT_GRID_CLASS grid template (BucketPage) and BucketListHeader/BucketRow share BUCKET_GRID_CLASS (BucketsPage) so header and rows track the same tracks; the header is made sticky inside the scroll container so it shares the body content box (no scrollbar-gutter drift). Fixes header/row column misalignment. Adds a list-alignment regression guard.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plan): v1 quality triage - upload staging, list refresh, alignment
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m10s
184dfea6f0
Execution plan tracking the three v1 quality issues through their developer/test/review cycles: Issue 1 upload stage->confirm (UploadDrawer), Issue 2 list-refresh-after-upload (transfer-store), Issue 3 list alignment (ADR #20). All three shipped through review; Wave 1 + Wave 2 complete and approved.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
chore: add mise.toml for tool configuration
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m5s
4137d7b3c3
ci: publish image to Forgejo registry + Trivy scan gate (ADRs #21, #22)
Some checks failed
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m43s
release / build + scan + publish (push) Failing after 34s
9e8fff81e2
Add release.yml (build -> Trivy scan -> push) and convert ci.yml to short-form action references. Dockerfile gains OCI provenance labels + build-args. Scan gate fails on CRITICAL, reports HIGH; scan runs before push. Tags: dev-<short-sha> from dev, v<tag> from tags, latest from main, plus sha-<full> provenance. Auth via PAT (PACKAGES_USER/PACKAGES_TOKEN).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
ci: ignore CVE-2026-59873 — base-image npm-bundled tar, not an app dep
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m41s
release / build + scan + publish (push) Successful in 47s
83d9cc0e13
First release-pipeline triage (ADR #22). tar 7.5.16 ships inside node:24-slim's bundled npm; absent from pnpm-lock.yaml and all package.jsons. Proxy runtime never invokes npm/tar; gzip-bomb DoS vector has no code path in the app.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: endpoint allowlist capability — spec + architecture baseline (ADRs #23–#29)
All checks were successful
release / build + scan + publish (push) Successful in 40s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m10s
6376e818ee
New capability spec endpoint-allowlist.md (F1–F16): mandatory positive endpoint-URL allowlist (fail-closed at boot), drop-down/free-text login UX driven by SHOW_ENDPOINTS_DROPDOWN with stealth list exposure (list sent to the client iff the drop-down is on), optional per-endpoint authorized buckets (filter at probe + reject on operation), and a public-cloud unscoped-bucket startup warning.

Spec updates: backend-proxy reverses the 'no endpoint allowlist' scope boundary; deployment-config adds the four new knobs; GLOSSARY + README index updated.

Architecture: lands the pending ADR batch — #23 (egress-guard framework) + #28 (endpoint URL canonicalization, shared) + #29 (allowlist policy: framework+policy in one pass, endpoint_forbidden 403, bucket guard reusing forbidden, /config conditional exposure, public-cloud warning). #24–#27 (rate limit, helmet CSP, request caps, /config posture) committed alongside as the pending security-hardening batch referenced by #29. ARCHITECTURE.md + COMPONENTS.md updated.

Plan: docs/plans/endpoint-allowlist.md (9-task, 4-wave execution plan).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(proxy): endpoint allowlist — egress guard + bucket guard + /config exposure
All checks were successful
release / build + scan + publish (push) Successful in 51s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m14s
3518a24216
Implements ADRs #23 (framework) + #28 (URL canonicalization) + #29 (policy). Mandatory positive exact-URL allowlist, fail-closed at boot, with per-endpoint authorized buckets and conditional /config list exposure.

Shared: endpoint-url.ts canonicalizer (normalizeEndpointUrl / isValidEndpointUrl, ADR #28 — scheme/host lowercase, default-port drop, trailing-slash strip, query/fragment/userinfo/percent-host rejected); endpoint_forbidden added to RelayErrorCodeSchema.

Proxy: egress-guard.ts (EgressPolicy, resolveEgressPolicy fail-closed resolver, validateEndpoint/assertEndpointAllowed with request-time ordering normalize → allowlist → DNS+classify; NO DNS on reject; address-class helpers defined+tested but unpopulated for v1; public-cloud unscoped-bucket warning AWS/GCS/Oracle, Azure excluded). bucket-guard.ts (assertBucketAllowed + filterBuckets, reuses existing forbidden). Guards wired into all 12 /s3 routes + /session/probe (insertion order: extractCredentials → assertEndpointAllowed → parse → assertBucketAllowed → clientFactory). ConfigParseError extracted to config-error.ts to break an egress-guard↔config cycle. endpoint_forbidden → 403 in STATUS_BY_CODE.

/config: three optional fields (showEndpointsDropdown, authorizedEndpoints, defaultEndpoint) emitted solely per SHOW_ENDPOINTS_DROPDOWN; DEFAULT_ENDPOINT only pre-selects, never drives exposure. proxyBasicAuthEnabled untouched (ADR #27 independence).

Tests: setup-env AUTHORIZED_ENDPOINTS fixture + permitAllEgressPolicy injection across the route-test blast radius; no-DNS-on-reject, insertion-order, filterBuckets-before-empty-check behavioral tests; F4 redact-harness covers endpoint_forbidden + bucket-forbidden reject paths. Compose files gain AUTHORIZED_ENDPOINTS=http://minio:9000 (fail-closed-at-boot fixture). Web DEFAULT_CONFIG omits the three new fields.

Gates: typecheck + lint + build + pnpm -r test (1505 tests) + test:integration (7, real MinIO) all green.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
feat(web): endpoint-allowlist login UX (drop-down/free-text) + E2E
All checks were successful
release / build + scan + publish (push) Successful in 1m4s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m28s
013b7c08f7
EA7: LoginPage reacts to /config — drop-down mode (a <select> populated from authorizedEndpoints, defaultEndpoint pre-selected per F9; placeholder + mandatory pick per F8 when no default) when showEndpointsDropdown is on; free-text <input> unchanged when off (stealth — no list in the browser). friendlyHeadline gains an endpoint_forbidden case. Native select chrome via color-scheme tokens; focus management + a real <label>. 13 login tests incl. the free-text no-list-in-DOM assertion.

EA9: 6 Playwright E2E cases — drop-down render / pre-selection / login-with-credentials-alone, and free-text stealth + endpoint_forbidden on a disallowed endpoint (server-side, no outbound connection). New tests/e2e/docker-compose.e2e.dropdown.yml for the drop-down stack variant.

docs: cite the locked per-endpoint bucket env-var name (ADR #29 §6) in deployment-config.md and endpoint-allowlist.md F10/Operator-knobs — was previously recorded as an architect decision/hint.

Gates: typecheck + lint + build + pnpm -r test (1511) + test:e2e (20) green. Feature complete.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: document mandatory AUTHORIZED_ENDPOINTS knob + retire endpoint-allowlist plan
All checks were successful
release / build + scan + publish (push) Successful in 1m24s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m35s
865ee02b12
The endpoint-allowlist feature shipped (ADR #23/#28/#29, commits 3518a24 + 013b7c0). AUTHORIZED_ENDPOINTS is now mandatory — a no-env docker run no longer boots. Update the README quickstart, security model, and configuration table with the four new knobs (AUTHORIZED_ENDPOINTS, DEFAULT_ENDPOINT, SHOW_ENDPOINTS_DROPDOWN, per-endpoint AUTHORIZED_BUCKETS). Delete the execution plan (docs/plans/endpoint-allowlist.md) per its lifecycle rule — the released code + git history are the record.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): reconcile security ADRs with landed allowlist + fix doc drift
All checks were successful
release / build + scan + publish (push) Successful in 42s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m16s
df8cb64d0e
ADR #29 §8: replace the interpolated warning template (unimplementable under the F4 lint rule) with the structured-logging form that shipped (static message + {endpoint, cloud, bucketEnvVar} fields).

ADR #24: cross-reference #29 (secure-by-default boot posture already landed for AUTHORIZED_ENDPOINTS; ALLOW_INSECURE_HTTP is the second such gate; shared compose-fixture concern); correct the wire-code count (two new, not three — endpoint_forbidden already landed via #23/#29).

COMPONENTS.md + ARCHITECTURE.md Config Service: hedge ADRs #26/#27 to 'planned' — they were documented in the present tense as if already implemented; describe current reality instead.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Wave A security hardening: rate-limit, HTTPS gate, CSP, request caps (ADRs #24-#27)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 2m8s
release / build + scan + publish (push) Successful in 2m16s
1555ac3a1d
- ADR #24: @fastify/rate-limit (global + /session/probe failure-only budget),
  ALLOW_INSECURE_HTTP gate (https_required 426) + TRUSTED_PROXIES -> trustProxy,
  connection/keepAlive timeouts (no requestTimeout; streamed transfers run for hours)
- ADR #25: @fastify/helmet strict header-based CSP (all verify-at-impl caveats
  resolved STRICT - no unsafe-inline/blob: widenings) + security headers; v1.1
  duckdb-wasm carry-over documented
- ADR #26: BULK_ZIP_MAX_FILES (bulkZipMaxFiles on /config) + MULTIPART_PART_MAX_SIZE
  two-layer body cap (Content-Length pre-check + byte-counting Transform); Fastify
  does not enforce bodyLimit on passthrough parsers (annotated inline on ADR #26)
- ADR #27: drop proxyBasicAuthEnabled from /config; no-colon fail-fast moved to
  basic-auth module (redacted error preserved)
- rawHeaders no-restricted-syntax lint rule (closes pino-redact gap)
- compose fixtures: ALLOW_INSECURE_HTTP=on (parallel to AUTHORIZED_ENDPOINTS)
- redact-harness coverage for rate_limited + https_required (standing rule)
- architecture docs flipped from 'pending' to present tense

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Wave B web UX: rate_limited/https_required handling + bulkZipMaxFiles pre-flight
Some checks failed
release / build + scan + publish (push) Successful in 57s
CI / pnpm gate (typecheck, lint, build, test) (push) Has been cancelled
e435a88b1c
- Login friendlyHeadline: rate_limited + https_required cases (ADR #24)
- Per-component banners (BucketPage ErrorBanner, ObjectOpsDialog OpError):
  rate_limited branch (no global relay-error handler exists)
- ADR #26 bulk-zip key-count cap: new TooManyFilesError variant in the
  TransferError union, handled in all 4 exhaustive copy switches;
  preflightBulkDownload checks count first (O(1) before O(n) size sum),
  boundary <= matches the proxy's inclusive .max(N) (no off-by-one)
- Copy is PROVISIONAL, marked at each site pending analyst sign-off

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
Update security-hardening plan tracker: both waves shipped
All checks were successful
release / build + scan + publish (push) Successful in 52s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m28s
9cb785a323
Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(architecture): fix #26 risks-table cell to match corrected two-layer cap mechanism
All checks were successful
release / build + scan + publish (push) Successful in 44s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m40s
78ef9dbd50
The cell still referenced Fastify's 'bodyLimit' — the mechanism ADR #26 §2's impl-note disproved (Fastify 5.11.2 doesn't enforce bodyLimit on passthrough parsers). Aligns the risks table with the corrected two-layer cap (Content-Length pre-check + byte-counting Transform) already documented in COMPONENTS.md and ADR #26 §2.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: analyst sign-off — complete knob inventory, disk-exposure disclosure, README fixes
All checks were successful
release / build + scan + publish (push) Successful in 1m12s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m25s
38dd240cda
deployment-config.md: add the 4 security-work knobs missing from the inventory (BULK_ZIP_MAX_FILES, MULTIPART_PART_MAX_SIZE, ALLOW_INSECURE_HTTP, TRUSTED_PROXIES); fix the stale 'omitting all overrides yields a working deployment' success criterion (now false — AUTHORIZED_ENDPOINTS is mandatory and ALLOW_INSECURE_HTTP=off refuses plaintext).

auth-session.md: add a disk-exposure disclosure FR on the 'Remember on this device' toggle + an NFR documenting the inherent plaintext-in-localStorage property (informed consent as the mitigation).

README: fix the quick start (it sent users to a 426 wall — now includes ALLOW_INSECURE_HTTP=true for the plaintext local trial + a steer to TLS for real deployments); add the 4 new knobs to the config table; update the security model for HTTPS-by-default + rate limiting + CSP; correct the stale 'use TLS when you arm basic auth' to 'TLS is required by default'.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
web: clear analyst-signoff PROVISIONAL copy + drop redundant too-many-files inline
All checks were successful
release / build + scan + publish (push) Successful in 53s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m18s
ea1fefc2d3
Point 1 of the security-hardening follow-ups. The analyst signed off the ADR #24/#26 user-facing copy, so remove every '// PROVISIONAL — pending analyst sign-off' marker from source and tests (adjacent explanatory comments retained).

Redundancy fix: the too-many-files error rendered its count+cap twice — once in the body (error.message) and again in an inline <p>. Drop the inline from RefusalError (DownloadDialog) and DownloadErrorBanner (BucketPage); the body is now the single source. Update the download-dialog assertion to the body phrasing. TransferPanel (whose body IS the inline-style string, rendered once) is unchanged.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
web: add "Remember on this device" disk-exposure disclosure on login
All checks were successful
release / build + scan + publish (push) Successful in 1m1s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m19s
21eb6e5860
Point 3 of the security-hardening follow-ups. Implements the new FR in docs/specs/auth-session.md: when the 'Remember on this device' toggle is ON, surface a disclosure that makes the localStorage plaintext-storage consequence explicit (informed consent). Additive UI only — no storage/session logic change.

The callout mirrors the login error-box structure (rounded border + bold uppercase headline + body) but uses neutral tokens (border-line/bg-surface-2) since this is informed consent, not an error. Shown only when 'remember' is on; hidden when off. Adds a login-page test asserting presence/absence across off -> on -> off.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(readme): drop internal references from user-facing README
All checks were successful
release / build + scan + publish (push) Successful in 1m2s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m30s
209d559286
Remove inline ADR parentheticals, the ARCHITECTURE.md link in the tech
stack, the 'ADR conventions' phrase, and the H1/e2e docker-compose bullets.
The 'Deeper documentation' section is intentionally kept as-is.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs: remove completed execution plans (all development verified shipped)
All checks were successful
release / build + scan + publish (push) Successful in 50s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m39s
5ae2dbc776
All 7 plans in docs/plans/ had their development work landed and verified\n(via git log + artifact checks). The folder is now empty; v1.1 plans will\nland there when planning starts. Non-plan residual: LICENSE file still\nabsent (Principal decision, flagged separately).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
proxy: add PROXY_BASIC_AUTH_FILE (Docker-secrets _FILE convention)
All checks were successful
release / build + scan + publish (push) Successful in 1m2s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m36s
870736e4fd
Resolve the secret-shaped PROXY_BASIC_AUTH credential via a new pure resolver
(basic-auth-secret.ts): PROXY_BASIC_AUTH_FILE wins outright over the plain
var when set and non-empty, else the plain var, else auth off. File contents
are trimmed of the common trailing newline (the downstream parser slices
untrimmed, so an untrimmed admin:pass\n would silently break auth); the
plain env-var path stays byte-identical. An unreadable _FILE path aborts
boot via ConfigParseError naming PROXY_BASIC_AUTH_FILE (fail-fast, ADR #27:
never silently downgraded), and the file PATH may appear in the error while
the CONTENTS never do. The resolved value flows into the unchanged
parseBasicAuthCredentials path.

Spec: docs/specs/deployment-config.md (new 'Secret-file resolution' section).
Tests: 23 new (resolver precedence/trim/fail-fast + extended F4 redact suite).

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(readme): split Quick start into from-source and Docker paths
All checks were successful
release / build + scan + publish (push) Successful in 49s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m32s
39f99cb379
Add a from-source run path (git clone, corepack prepare, pnpm build, node packages/proxy/dist/server.js) alongside the Docker path, which now pulls the prebuilt registry image (forgejo.xreveillon.eu/xavier/s3-vedrfolnir:latest) instead of building locally. Shared explanatory material (the two-knob note, health check) now clearly applies to both paths.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
docs(plans): record v1.0.1 candidate backlog (bugs + QoL)
All checks were successful
CI / pnpm gate (typecheck, lint, build, test) (pull_request) Successful in 1m17s
CI / pnpm gate (typecheck, lint, build, test) (push) Successful in 1m24s
release / build + scan + publish (push) Successful in 40s
a0b14c50ce
Triaged after the v1.0 acceptance sweep: missing bulk-delete, stale selection count, invisible light-theme checkmark, row menu occlusion, and per-dialog 'I understand' persistence. Architect not engaged (Principal's instruction); decisions on checkbox granularity (four keys) and the confirm-button guardrail recorded inline.

Co-Authored-By: Xavier's assistant <assistant@gijoe88.com>
xavier merged commit a0b14c50ce into main 2026-08-10 18:58:19 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
xavier/s3-vedrfolnir!1
No description provided.