Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e64dbc0
feat(upload): reject over-limit files in the browser before transferr…
BigSimmo Jul 22, 2026
8e0dd7d
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
eea934a
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
43b5e74
Merge remote-tracking branch 'origin/claude/upload-size-precheck-123366'
BigSimmo Jul 22, 2026
d51e2fb
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
beee1fc
Merge remote-tracking branch 'origin/claude/upload-size-precheck-123366'
BigSimmo Jul 22, 2026
c947c92
fix(upload): align client precheck with optional public max MB
BigSimmo Jul 22, 2026
c023b92
style(upload): prettier-format DocumentManagerPanel precheck changes
BigSimmo Jul 22, 2026
ab53ff0
Merge origin/main into the upload pre-check branch
BigSimmo Jul 22, 2026
0900a1e
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
f087632
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
ee6d038
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
65022b4
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
c04b859
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
f884f80
Merge branch 'main' into claude/upload-size-precheck-123366
BigSimmo Jul 22, 2026
aedf11e
Merge remote-tracking branch 'origin/main' into run-pr/1069-fix
BigSimmo Jul 22, 2026
2f3b2b8
fix(upload): wire public max MB through Docker builds and pin DOM tests
BigSimmo Jul 22, 2026
7e2adc7
Merge remote-tracking branch 'origin/claude/upload-size-precheck-1233…
BigSimmo Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -162,6 +162,12 @@ SUPABASE_IMAGE_BUCKET=clinical-images
# Conservative local-first defaults. Raise only after testing your worker machine,
# Supabase plan limits, and confidentiality policy.
MAX_UPLOAD_MB=150
# Optional browser mirror of MAX_UPLOAD_MB. When you lower MAX_UPLOAD_MB, set
# the same value here so the Document Manager pre-check and hint match the
# server before a large transfer starts. Clamped to 150; unset falls back to 150.
# For Railway/Docker production images this is a BUILD-TIME variable (Dockerfile
# ARG/ENV before npm run build) — mirror lowered MAX_UPLOAD_MB before rebuilding.
# NEXT_PUBLIC_MAX_UPLOAD_MB=150
# Next Proxy has a fixed 151 MiB transport envelope (150 MiB file plus
# multipart framing), so values above 150 are intentionally rejected.
MAX_CONCURRENT_UPLOADS=1
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docker-image.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,6 +74,7 @@ jobs:
build-args: |
NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-ci-publishable-key
NEXT_PUBLIC_MAX_UPLOAD_MB=

worker-image:
runs-on: ubuntu-24.04
Expand Down
6 changes: 6 additions & 0 deletions Dockerfile
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
# production image:
# docker build \
# --build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... \
# --build-arg NEXT_PUBLIC_MAX_UPLOAD_MB=150 \
# -t clinical-kb-app .
# Server-side secrets (SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, ...) are
# NEVER baked into the image — inject them at run time from the host's
Expand All@@ -33,8 +34,13 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co
ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=placeholder-build-publishable-key
# Optional browser upload-limit mirror (clamped client-side). Must be set at
# build time to inline into the client bundle — runtime Railway vars alone are
# not enough when operators lower MAX_UPLOAD_MB.
ARG NEXT_PUBLIC_MAX_UPLOAD_MB=
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
ENV NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}
ENV NEXT_PUBLIC_MAX_UPLOAD_MB=${NEXT_PUBLIC_MAX_UPLOAD_MB}
# The repo build script allocates an 8 GiB heap; give the builder >= 10 GiB.
RUN npm run build

Expand Down
6 changes: 6 additions & 0 deletions docs/deployment-architecture.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,12 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host.
setting them as service variables inlines the real values. The publishable key
is public by design; the placeholder default exists so CI can build without
secrets. **Production images must be built with the real publishable key.**
- `NEXT_PUBLIC_MAX_UPLOAD_MB` is also a build-time public variable (Docker
`ARG`/`ENV` before `npm run build`). When operators lower server-side
`MAX_UPLOAD_MB`, mirror the same value in `NEXT_PUBLIC_MAX_UPLOAD_MB` before
building the production image so the browser precheck rejects over-limit
files without a full transfer. Runtime-only Railway variables are not enough
for this value because Next inlines `NEXT_PUBLIC_*` at build time.
- Runtime is a non-root `node` user, prod-only `node_modules`, direct
`next start -H 0.0.0.0 -p $PORT` (Railway injects `$PORT`; the local
port-picker script is deliberately bypassed), and a `HEALTHCHECK` against
Expand Down
20 changes: 10 additions & 10 deletions src/components/clinical-dashboard/DocumentManagerPanel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,7 @@ import {
} from "@/components/ui-primitives";
import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text";
import { emptyStates, errorCopy } from "@/lib/ui-copy";
import { MAX_UPLOAD_MB_CEILING, exceedsUploadSizeCeiling, uploadSizeLimitMessage } from "@/lib/upload-limits";
import { exceedsClientUploadSize, getClientMaxUploadMb, uploadSizeLimitMessage } from "@/lib/upload-limits";
import { StatusBadge } from "@/components/clinical-dashboard/badges";
import { PrivacyInputNotice } from "@/components/privacy-input-notice";
import type { ClinicalDocument, IngestionJob, ImportBatch } from "@/lib/types";
Expand DownExpand Up@@ -335,19 +335,19 @@ export function UploadPanel({
for (let index = 0; index < files.length; index++) {
const file = files[index];
try {
// Pre-check the size before spending the transfer. The server caps every
// file at env.MAX_UPLOAD_MB, which its schema can never raise above
// MAX_UPLOAD_MB_CEILING, so an over-ceiling file is a guaranteed 413 —
// uploading it first only makes the clinician wait for the rejection.
// The rest of the batch still uploads, matching the server's per-file
// outcome semantics.
if (exceedsUploadSizeCeiling(file.size)) {
// Pre-check the size before spending the transfer. Prefer
// NEXT_PUBLIC_MAX_UPLOAD_MB (clamped to the ceiling) so a lowered
// operator limit matches the UI; the server still enforces
// env.MAX_UPLOAD_MB as the authority. The rest of the batch still
// uploads, matching the server's per-file outcome semantics.
const clientMaxUploadMb = getClientMaxUploadMb();
if (exceedsClientUploadSize(file.size)) {
outcomes.push({
kind: "failed",
fileName: file.name,
status: 413,
code: "payload_too_large",
message: uploadSizeLimitMessage(MAX_UPLOAD_MB_CEILING),
message: uploadSizeLimitMessage(clientMaxUploadMb),
});
continue;
}
Expand DownExpand Up@@ -424,7 +424,7 @@ export function UploadPanel({
/>
</label>
<p id={fileHintId} className={cn(textMuted, "mt-2 text-xs")}>
PDF only, up to {MAX_UPLOAD_MB_CEILING} MB per file.
PDF only, up to {getClientMaxUploadMb()} MB per file.
</p>
<div className="mt-3">
<button
Expand Down
26 changes: 23 additions & 3 deletions src/lib/upload-limits.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,16 +8,36 @@
*
* The server's effective limit is `env.MAX_UPLOAD_MB`, whose schema caps it at
* `MAX_UPLOAD_MB_CEILING` — an operator can configure a *lower* limit, never a
* higher one. A browser-side pre-check at the ceiling therefore only ever
* rejects a file the server was certain to reject too; anything under it is
* still sent, and the server remains the authority.
* higher one. The browser pre-check reads optional `NEXT_PUBLIC_MAX_UPLOAD_MB`
* (clamped to the same ceiling) so the UI can match a lowered server limit;
* when that public env is unset it falls back to the ceiling. The server
* remains the authority for anything that still reaches `/api/upload`.
*/

/** Hard ceiling for a single uploaded file, in MB. */
export const MAX_UPLOAD_MB_CEILING = 150;

const BYTES_PER_MB = 1024 * 1024;

/**
* Effective client-side upload limit in MB.
* Reads `NEXT_PUBLIC_MAX_UPLOAD_MB` when set to a positive integer, clamps to
* the ceiling, and otherwise uses the ceiling (safe default when operators
* have not mirrored a lowered `MAX_UPLOAD_MB`).
*/
export function getClientMaxUploadMb(): number {
const raw = process.env.NEXT_PUBLIC_MAX_UPLOAD_MB?.trim();
Comment thread
BigSimmo marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
if (!raw) return MAX_UPLOAD_MB_CEILING;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) return MAX_UPLOAD_MB_CEILING;
return Math.min(parsed, MAX_UPLOAD_MB_CEILING);
}

/** True when a file exceeds the effective client pre-check limit. */
export function exceedsClientUploadSize(sizeInBytes: number): boolean {
return sizeInBytes > getClientMaxUploadMb() * BYTES_PER_MB;
}

/** True when a file is larger than any limit the server can be configured to accept. */
export function exceedsUploadSizeCeiling(sizeInBytes: number): boolean {
return sizeInBytes > MAX_UPLOAD_MB_CEILING * BYTES_PER_MB;
Expand Down
25 changes: 23 additions & 2 deletions tests/upload-size-precheck.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,8 +9,10 @@ import { MAX_UPLOAD_MB_CEILING } from "@/lib/upload-limits";
// pre-check an over-ceiling file is transferred in full before the server
// answers 413 — on a large guideline PDF over a clinic connection that is a
// long wait for a guaranteed rejection. These tests pin that the pre-check
// fires locally, that it does NOT swallow files the server might still accept,
// and that a mixed batch still uploads its valid files.
// fires locally (using NEXT_PUBLIC_MAX_UPLOAD_MB when set, else the ceiling),
// that it does NOT swallow files the effective client limit still accepts,
// and that a mixed batch still uploads its valid files. The server remains
// the authority via env.MAX_UPLOAD_MB for anything that reaches /api/upload.

type OpenedRequest = { method: string; url: string };

Expand DownExpand Up@@ -74,10 +76,15 @@ beforeEach(() => {
FakeXhr.lastStatus = 200;
FakeXhr.lastResponse = JSON.stringify({ document: { id: "doc-1" }, job: { id: "job-1" } });
vi.stubGlobal("XMLHttpRequest", FakeXhr);
vi.unstubAllEnvs();
Comment thread
BigSimmo marked this conversation as resolved.
// Pin empty so default-limit assertions stay deterministic when the shell/CI
// already exports NEXT_PUBLIC_MAX_UPLOAD_MB (vi.unstubAllEnvs restores it).
vi.stubEnv("NEXT_PUBLIC_MAX_UPLOAD_MB", "");
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -126,4 +133,18 @@ describe("upload size pre-check", () => {
// Exactly one request: the oversized file never reached the network.
expect(opened).toHaveLength(1);
});

it("honours a lowered NEXT_PUBLIC_MAX_UPLOAD_MB for the hint and pre-check", async () => {
vi.stubEnv("NEXT_PUBLIC_MAX_UPLOAD_MB", "50");
const { onUploaded } = renderPanel();

expect(screen.getByText("PDF only, up to 50 MB per file.")).toBeVisible();

selectFiles([fileOfSize("mid-guideline.pdf", 51)]);
submit();

expect(await screen.findByText(/mid-guideline\.pdf/)).toHaveTextContent("File exceeds 50 MB upload limit.");
expect(opened).toHaveLength(0);
expect(onUploaded).not.toHaveBeenCalled();
});
});
Loading