Merge pull request 'feat(uploads): /uploads/limits SSOT endpoint + Go-side convergence (task #320)' (#1604) from feat/uploads-limits-ssot-task-320 into main
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 21s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 5m15s
CI / Platform (Go) (push) Successful in 5m18s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m15s
CI / Canvas (Next.js) (push) Successful in 6m40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m36s
Harness Replays / Harness Replays (push) Successful in 2s
CI / Python Lint & Test (push) Successful in 6m51s
CI / all-required (push) Successful in 6m43s
gitea-merge-queue / queue (push) Successful in 6s
CI / Canvas Deploy Reminder (push) Successful in 1s
status-reaper / reap (push) Successful in 1m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m35s
E2E Chat / E2E Chat (push) Failing after 7m7s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m52s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 17s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Waiting to run
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
CI / Detect changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 21s
E2E API Smoke Test / detect-changes (push) Successful in 14s
E2E Chat / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Harness Replays / detect-changes (push) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 4s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-workspace-server-image / build-and-push (push) Successful in 5m15s
CI / Platform (Go) (push) Successful in 5m18s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m15s
CI / Canvas (Next.js) (push) Successful in 6m40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 1m36s
Harness Replays / Harness Replays (push) Successful in 2s
CI / Python Lint & Test (push) Successful in 6m51s
CI / all-required (push) Successful in 6m43s
gitea-merge-queue / queue (push) Successful in 6s
CI / Canvas Deploy Reminder (push) Successful in 1s
status-reaper / reap (push) Successful in 1m12s
publish-workspace-server-image / Production auto-deploy (push) Successful in 5m50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m35s
E2E Chat / E2E Chat (push) Failing after 7m7s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m52s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 17s
This commit was merged in pull request #1604.
This commit is contained in:
@@ -49,6 +49,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/uploads"
|
||||
)
|
||||
|
||||
// ChatFilesHandler serves file upload + download for chat. Holds a
|
||||
@@ -112,19 +113,26 @@ func (h *ChatFilesHandler) WithPendingUploads(storage pendinguploads.Storage, br
|
||||
}
|
||||
|
||||
// chatUploadMaxBytes caps the full multipart request body so a
|
||||
// malicious / runaway client can't OOM the proxy hop. 100 MB matches
|
||||
// the workspace-side limit; anything larger is rejected at the
|
||||
// network boundary before forwarding.
|
||||
// malicious / runaway client can't OOM the proxy hop. Derived from the
|
||||
// SSOT in internal/uploads (task #320): bumping the cap is one edit in
|
||||
// internal/uploads/limits.go, never a synchronized 5-surface bump.
|
||||
//
|
||||
// CANVAS_MIRROR: keep aligned with canvas/src/components/tabs/chat/
|
||||
// uploads.ts MAX_UPLOAD_BYTES. The canvas constant exists so the
|
||||
// pre-flight size check can fail immediately (before network I/O)
|
||||
// with the actionable "File too large (got X MB) — limit is 100MB"
|
||||
// message. Bumping one side without the other yields the wrong-reason
|
||||
// surface that motivated this constant pair (CTO 2026-05-19 directive
|
||||
// on forensic a99ab0a1: file-size cause MUST surface as file-size,
|
||||
// NOT as a downstream timeout).
|
||||
const chatUploadMaxBytes = 100 * 1024 * 1024
|
||||
// CANVAS_MIRROR: canvas/src/components/tabs/chat/uploads.ts reads the
|
||||
// live value via GET /uploads/limits at app init (Phase 2 follow-up;
|
||||
// today still a literal 100 MB pinned by an assertion test, which the
|
||||
// migration PR will replace). The canvas constant exists so the
|
||||
// pre-flight size check can fail immediately (before network I/O) with
|
||||
// the actionable "File too large (got X MB) — limit is 100MB" message.
|
||||
// Bumping one side without the other yields the wrong-reason surface
|
||||
// that motivated this constant pair (CTO 2026-05-19 directive on
|
||||
// forensic a99ab0a1: file-size cause MUST surface as file-size, NOT as
|
||||
// a downstream timeout). Once the canvas migrates to the live fetch,
|
||||
// drift becomes structurally impossible.
|
||||
//
|
||||
// Why "var" instead of "const": Go disallows initializing a const from
|
||||
// a function call. The DefaultUploadLimits() body is pure literals so
|
||||
// the runtime cost is zero.
|
||||
var chatUploadMaxBytes = uploads.DefaultUploadLimits().PerRequestBytes
|
||||
|
||||
// resolveWorkspaceForwardCreds resolves the workspace's URL +
|
||||
// platform_inbound_secret for an /internal/* forward, applying
|
||||
@@ -620,7 +628,7 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
prepReady := make([]prepped, 0, len(headers))
|
||||
items := make([]pendinguploads.PutItem, 0, len(headers))
|
||||
for _, fh := range headers {
|
||||
if fh.Size > pendinguploads.MaxFileBytes {
|
||||
if fh.Size > int64(pendinguploads.MaxFileBytes) {
|
||||
log.Printf("chat_files uploadPollMode: per-file cap exceeded for %s: %s (%d bytes)",
|
||||
workspaceID, fh.Filename, fh.Size)
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
|
||||
|
||||
@@ -586,7 +586,7 @@ func TestPollUpload_PerFileCapPreStorage_413(t *testing.T) {
|
||||
// next bumped above MaxFileBytes (e.g., RFC for the SSOT
|
||||
// GET /uploads/limits endpoint reshapes the layering) this test
|
||||
// can run again.
|
||||
if pendinguploads.MaxFileBytes >= chatUploadMaxBytes {
|
||||
if int64(pendinguploads.MaxFileBytes) >= chatUploadMaxBytes {
|
||||
t.Skipf("per-file cap %d >= body cap %d; the body MaxBytesReader 400s before the per-file 413 branch is reachable. Re-enable when body cap > per-file cap.",
|
||||
pendinguploads.MaxFileBytes, chatUploadMaxBytes)
|
||||
}
|
||||
@@ -686,7 +686,7 @@ func TestPollUpload_AtomicRollbackOnSecondFileTooLarge(t *testing.T) {
|
||||
// a real Postgres without depending on the body-cap arithmetic
|
||||
// here. Re-enable this handler-level test when body cap exceeds
|
||||
// per-file cap again.
|
||||
if pendinguploads.MaxFileBytes >= chatUploadMaxBytes {
|
||||
if int64(pendinguploads.MaxFileBytes) >= chatUploadMaxBytes {
|
||||
t.Skipf("per-file cap %d >= body cap %d; the body MaxBytesReader 400s before the per-file 413 branch is reachable. Storage-level atomicity covered by integration test. Re-enable when body cap > per-file cap.",
|
||||
pendinguploads.MaxFileBytes, chatUploadMaxBytes)
|
||||
}
|
||||
|
||||
@@ -37,21 +37,37 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/uploads"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Per-file size cap. Mirrors workspace-side ingest_handler
|
||||
// (workspace/internal_chat_uploads.py:CHAT_UPLOAD_MAX_FILE_BYTES) and the
|
||||
// push-mode chat upload cap (chat_files.go:chatUploadMaxBytes). Pinned at
|
||||
// the DB level via the size_bytes CHECK constraint (currently
|
||||
// 104857600 per migration 20260519200000_pending_uploads_bump_size_cap);
|
||||
// this Go-side constant exists so the Put implementation can reject
|
||||
// before round-tripping to Postgres.
|
||||
// MaxFileBytes is the per-file size cap enforced by Put / PutBatch
|
||||
// before any DB round-trip. Mirrors workspace-side ingest
|
||||
// (workspace/internal_chat_uploads.py:CHAT_UPLOAD_MAX_FILE_BYTES) and
|
||||
// push-mode chat upload cap (chat_files.go:chatUploadMaxBytes). Also
|
||||
// pinned at the DB level via the pending_uploads.size_bytes CHECK
|
||||
// constraint (currently <=104857600 per migration
|
||||
// 20260519200000_pending_uploads_bump_size_cap); this Go-side const
|
||||
// exists so a 100 MB+1 byte payload is rejected before Postgres has
|
||||
// to look at it.
|
||||
//
|
||||
// Kept consistent with push-mode (mc#1588) per CTO directive 2026-05-19.
|
||||
// SSOT follow-up: GET /uploads/limits will let every surface read the
|
||||
// live cap rather than each pinning its own copy.
|
||||
const MaxFileBytes = 100 * 1024 * 1024
|
||||
// SSOT (task #320): the value derives from uploads.DefaultUploadLimits()
|
||||
// — the single source consumed by GET /uploads/limits. Bumping the cap
|
||||
// is a one-line edit in internal/uploads/limits.go; this constant
|
||||
// follows. The migration's size_bytes CHECK upper bound must be raised
|
||||
// in lockstep (separate migration file) since DB constraints can't read
|
||||
// Go vars at runtime.
|
||||
//
|
||||
// Why "var" instead of "const": Go disallows initializing a const from
|
||||
// a function call. The runtime cost is zero (DefaultUploadLimits is a
|
||||
// pure literal-builder) and tests can still treat it as effectively
|
||||
// immutable — no caller mutates it.
|
||||
//
|
||||
// Why "int" instead of "int64": the existing API surface is
|
||||
// len(content)-comparison + make([]byte, MaxFileBytes+1) call sites,
|
||||
// both of which want int. We convert from the int64 SSOT field once,
|
||||
// here, rather than thread int64 through every caller.
|
||||
var MaxFileBytes = int(uploads.DefaultUploadLimits().PerFileBytes)
|
||||
|
||||
// ErrNotFound is returned by Get / MarkFetched / Ack when the row is
|
||||
// absent. Callers turn this into HTTP 404. Treat acked + expired rows
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/uploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/gin-contrib/cors"
|
||||
@@ -105,6 +106,24 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
c.JSON(200, gin.H{"git_sha": buildinfo.GitSHA})
|
||||
})
|
||||
|
||||
// Upload limits — public, no auth. Single source of truth for
|
||||
// per-file / per-request / max-attachments caps consumed by the
|
||||
// canvas (chat upload pre-flight), the workspace python ingest
|
||||
// (push + poll), and any future client. Background: task #320 +
|
||||
// the SSOT-follow-up markers in pendinguploads/storage.go +
|
||||
// handlers/chat_files.go + canvas/.../chat/uploads.ts. Existence
|
||||
// reason — mc#1588 raised push-mode caps and mc#1589 had to catch
|
||||
// up the poll-mode + DB CHECK side a day later because the
|
||||
// constants were duplicated across 5 surfaces. Public is
|
||||
// intentional: these are platform constraints every uploader
|
||||
// already learns the hard way via a 413 — exposing them via API
|
||||
// removes the "guess the cap then retry on rejection" UX.
|
||||
// Cached in the binary via uploads.DefaultUploadLimits(); no DB
|
||||
// round-trip per request.
|
||||
r.GET("/uploads/limits", func(c *gin.Context) {
|
||||
c.JSON(200, uploads.DefaultUploadLimits())
|
||||
})
|
||||
|
||||
// /admin/liveness — per-subsystem last-tick timestamps. Operators read this
|
||||
// to catch stuck-but-not-crashed goroutines (the failure mode that caused
|
||||
// the 12h scheduler outage of 2026-04-14, issue #85). Any subsystem whose
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/uploads"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// uploads_limits_route_test.go — task #320 SSOT endpoint.
|
||||
//
|
||||
// The /uploads/limits route is the single point every consumer reads
|
||||
// to learn the per-file / per-request / max-attachments caps. Without
|
||||
// this test, a future router refactor could silently drop the route
|
||||
// (consumers degrade to cached / hard-coded defaults — exactly the
|
||||
// drift the endpoint exists to prevent) or mount it under an auth
|
||||
// group (which would 401 the canvas's pre-auth call from a logged-out
|
||||
// browser tab).
|
||||
//
|
||||
// The contract being pinned:
|
||||
// 1. The route is registered and reachable.
|
||||
// 2. The route is PUBLIC — no AdminAuth, no WorkspaceAuth. The cap
|
||||
// values are platform constraints, not operational state; gating
|
||||
// them would force every uploader to authenticate before learning
|
||||
// the size limit, which defeats the pre-flight UX.
|
||||
// 3. The wire shape matches uploads.UploadLimits exactly (same JSON
|
||||
// keys, same values as DefaultUploadLimits).
|
||||
// 4. The in-tree Go consumers (pendinguploads.MaxFileBytes,
|
||||
// handlers.chatUploadMaxBytes) AGREE with the endpoint's value.
|
||||
// This is what makes the package an actual SSOT instead of just a
|
||||
// copy of the same literal — a future PR that bumps the Go const
|
||||
// without bumping DefaultUploadLimits (or vice versa) fails here.
|
||||
|
||||
// buildUploadsLimitsEngine builds a minimal Gin engine with ONLY the
|
||||
// /uploads/limits route registered the same way router.Setup does. We
|
||||
// don't go through Setup() because it requires the full dependency
|
||||
// graph (DB, hub, broadcaster, provisioner) — none of which the
|
||||
// endpoint actually consumes. The route is a pure literal.
|
||||
func buildUploadsLimitsEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/uploads/limits", func(c *gin.Context) {
|
||||
c.JSON(200, uploads.DefaultUploadLimits())
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestUploadsLimits_Public_Returns200(t *testing.T) {
|
||||
r := buildUploadsLimitsEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/uploads/limits", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsLimits_ReturnsDefaultValues(t *testing.T) {
|
||||
r := buildUploadsLimitsEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/uploads/limits", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var got uploads.UploadLimits
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
|
||||
}
|
||||
|
||||
want := uploads.DefaultUploadLimits()
|
||||
if got != want {
|
||||
t.Errorf("endpoint payload diverged from DefaultUploadLimits:\n got: %+v\n want: %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsLimits_AgreesWith_InTreeGoConsumers(t *testing.T) {
|
||||
// The whole point of task #320 is that the Go in-process consumers
|
||||
// (pendinguploads.MaxFileBytes, handlers.chatUploadMaxBytes) and the
|
||||
// wire-exposed endpoint return the SAME number. This test fails if a
|
||||
// future change bumps one without bumping the other — exactly the
|
||||
// drift class that motivated mc#1588 → mc#1589.
|
||||
//
|
||||
// chatUploadMaxBytes is unexported so we can't import it directly;
|
||||
// it derives from the same DefaultUploadLimits().PerRequestBytes
|
||||
// expression and is covered by the existing handler tests. We pin
|
||||
// pendinguploads.MaxFileBytes here as the exported Go-side mirror.
|
||||
want := uploads.DefaultUploadLimits().PerFileBytes
|
||||
if int64(pendinguploads.MaxFileBytes) != want {
|
||||
t.Errorf("pendinguploads.MaxFileBytes diverged from SSOT:\n pendinguploads: %d\n uploads SSOT: %d",
|
||||
pendinguploads.MaxFileBytes, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package uploads is the single source of truth for chat-upload sizing
|
||||
// constraints across every layer of the platform.
|
||||
//
|
||||
// Before this package existed the same numbers were duplicated across at
|
||||
// least five surfaces:
|
||||
//
|
||||
// 1. workspace-server Go const — pendinguploads.MaxFileBytes
|
||||
// 2. workspace-server Go const — handlers.chatUploadMaxBytes
|
||||
// 3. workspace Python module — workspace/inbox_uploads.MAX_FILE_BYTES
|
||||
// 4. workspace Python module — workspace/internal_chat_uploads
|
||||
// .CHAT_UPLOAD_MAX_BYTES / .CHAT_UPLOAD_MAX_FILE_BYTES
|
||||
// 5. canvas TypeScript const — canvas/.../chat/uploads.ts MAX_UPLOAD_BYTES
|
||||
//
|
||||
// plus a sixth (the DB CHECK on pending_uploads.size_bytes) and a seventh
|
||||
// (the nginx test-harness client_max_body_size).
|
||||
//
|
||||
// Every cap change required a coordinated edit across all of them. mc#1588
|
||||
// raised push-mode (1, 2, 4, 5, 7) from 50 MB to 100 MB on 2026-05-20;
|
||||
// the matching poll-mode + DB CHECK bumps (3, 6, parts of pendinguploads)
|
||||
// were missed and shipped a day later as mc#1589 (drift window: one day,
|
||||
// production confusion: "why does push work but poll reject the same
|
||||
// file?"). The same drift class is guaranteed to recur on every future cap
|
||||
// change unless the constants converge.
|
||||
//
|
||||
// This package + the GET /uploads/limits endpoint are the convergence
|
||||
// point. The Go consumers reference DefaultUploadLimits() directly; the
|
||||
// out-of-process consumers (workspace Python, canvas TS, python ingest)
|
||||
// can fetch the limits via the public endpoint at startup and cache them.
|
||||
// The migration that defines the DB CHECK references the same numerical
|
||||
// constant via a -- comment so a reviewer can see at a glance whether a
|
||||
// new migration is in sync with the Go default.
|
||||
//
|
||||
// Task tracking: molecule-ai/internal #320 + the legacy SSOT-follow-up
|
||||
// markers in pendinguploads/storage.go, handlers/chat_files.go, and
|
||||
// canvas/src/components/tabs/chat/uploads.ts.
|
||||
package uploads
|
||||
|
||||
// UploadLimits is the wire shape returned by GET /uploads/limits and the
|
||||
// in-process type read by every Go consumer. The JSON tags are part of
|
||||
// the stable public contract — renaming or removing a field is a
|
||||
// breaking change for the canvas + Python consumers.
|
||||
//
|
||||
// New fields MAY be added without a major bump (consumers ignore unknown
|
||||
// keys), but every existing field must keep its name + units forever or
|
||||
// roll out a v2 endpoint.
|
||||
type UploadLimits struct {
|
||||
// PerFileBytes is the hard cap on a single uploaded file. Enforced
|
||||
// in three places: the platform-side handler in chat_files.go
|
||||
// (push + poll paths), the workspace-side ingest in
|
||||
// internal_chat_uploads.py (push) + inbox_uploads.py (poll), and
|
||||
// the canvas-side pre-flight gate before any network I/O. The DB
|
||||
// CHECK on pending_uploads.size_bytes also enforces this value for
|
||||
// the poll-mode staging table.
|
||||
PerFileBytes int64 `json:"per_file_bytes"`
|
||||
|
||||
// PerRequestBytes is the hard cap on the full multipart request
|
||||
// body. With one attachment + minimal multipart framing this is
|
||||
// effectively equal to PerFileBytes; with N attachments it bounds
|
||||
// the sum. Today we keep them equal at 100 MB — a multi-file batch
|
||||
// must collectively fit under the same ceiling as a single large
|
||||
// file. If we ever decouple them (e.g. raise per-request to allow
|
||||
// a 200 MB batch of 50 MB files) this field is where that lands.
|
||||
PerRequestBytes int64 `json:"per_request_bytes"`
|
||||
|
||||
// MaxAttachmentsPerMessage caps the count of files in a single
|
||||
// /chat/uploads request. Defends against a pathological client that
|
||||
// streams 10 000 1-byte files (which would each spawn a row in
|
||||
// pending_uploads, exhaust file descriptors on the workspace side,
|
||||
// and slow chat_files.uploadPollMode's per-file loop to a crawl).
|
||||
// Currently advisory only — consumers are free to read it but no
|
||||
// platform handler enforces it as of task #320 Phase 1. Will be
|
||||
// enforced once the canvas + workspace consumers have rolled.
|
||||
MaxAttachmentsPerMessage int `json:"max_attachments_per_message"`
|
||||
}
|
||||
|
||||
// DefaultUploadLimits returns the production defaults. This is THE
|
||||
// source: every other constant in the codebase that mentions an upload
|
||||
// cap must derive from this function, NOT from a duplicated literal.
|
||||
//
|
||||
// Why a function and not a package-level var: a var would be mutable at
|
||||
// runtime and create the "test modified it and forgot to reset it" class
|
||||
// of flake. Callers that need a per-test override should pass a custom
|
||||
// UploadLimits value through the handler/registration site, not mutate
|
||||
// a global. (No such override exists today; if one is needed in the
|
||||
// future, prefer a WithLimits(UploadLimits) wiring option over a
|
||||
// SetDefault function.)
|
||||
//
|
||||
// Values pinned at 100 MB per CTO directive 2026-05-19, in lockstep
|
||||
// with mc#1588 + mc#1589. Bumping the cap is a coordinated multi-PR
|
||||
// dance: raise this default, ship a DB migration that loosens the
|
||||
// pending_uploads.size_bytes CHECK, raise the nginx
|
||||
// client_max_body_size in tests/harness/cf-proxy/nginx.conf, and
|
||||
// confirm both push-mode + poll-mode E2E. The whole point of this
|
||||
// package is that step 1 is now ONE edit instead of 5.
|
||||
func DefaultUploadLimits() UploadLimits {
|
||||
return UploadLimits{
|
||||
PerFileBytes: 100 * 1024 * 1024, // 100 MB
|
||||
PerRequestBytes: 100 * 1024 * 1024, // 100 MB
|
||||
MaxAttachmentsPerMessage: 10,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultUploadLimits_PinsCurrentValues guards against a silent
|
||||
// cap change. Any future bump MUST update this test as part of the
|
||||
// same PR — that forces a reviewer to see the cap move and audit the
|
||||
// matching DB migration + nginx config + python/canvas consumer updates.
|
||||
//
|
||||
// If you're updating this test because you bumped the cap: also update
|
||||
// (1) the matching migration's size_bytes CHECK upper bound, (2)
|
||||
// tests/harness/cf-proxy/nginx.conf client_max_body_size, (3) the doc
|
||||
// comments in handlers/chat_files.go + pendinguploads/storage.go +
|
||||
// canvas/.../uploads.ts that quote the cap in English ("100 MB").
|
||||
func TestDefaultUploadLimits_PinsCurrentValues(t *testing.T) {
|
||||
got := DefaultUploadLimits()
|
||||
const oneHundredMB = int64(100 * 1024 * 1024)
|
||||
|
||||
if got.PerFileBytes != oneHundredMB {
|
||||
t.Errorf("PerFileBytes: want %d (100 MB), got %d", oneHundredMB, got.PerFileBytes)
|
||||
}
|
||||
if got.PerRequestBytes != oneHundredMB {
|
||||
t.Errorf("PerRequestBytes: want %d (100 MB), got %d", oneHundredMB, got.PerRequestBytes)
|
||||
}
|
||||
if got.MaxAttachmentsPerMessage != 10 {
|
||||
t.Errorf("MaxAttachmentsPerMessage: want 10, got %d", got.MaxAttachmentsPerMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadLimits_JSONShape pins the wire contract. Renaming any of
|
||||
// these JSON keys is a breaking change for the canvas + Python
|
||||
// consumers that fetch GET /uploads/limits. Adding new keys is fine;
|
||||
// renaming or removing requires a new endpoint (v2) and a coordinated
|
||||
// consumer rollout.
|
||||
//
|
||||
// We assert via Marshal+Unmarshal-through-map rather than a literal
|
||||
// JSON string match because Go map ordering in error messages is
|
||||
// stable but a literal would catch every whitespace tweak; this
|
||||
// formulation surfaces the actual field-name regression.
|
||||
func TestUploadLimits_JSONShape(t *testing.T) {
|
||||
in := UploadLimits{
|
||||
PerFileBytes: 1,
|
||||
PerRequestBytes: 2,
|
||||
MaxAttachmentsPerMessage: 3,
|
||||
}
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Field names — exact strings the canvas TS + python clients will
|
||||
// key off. Any rename here is a coordinated multi-repo rollout.
|
||||
for _, key := range []string{"per_file_bytes", "per_request_bytes", "max_attachments_per_message"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Errorf("missing JSON key %q in %s", key, string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip preserves values — guards against silently changing
|
||||
// the field encoding (e.g. int → string).
|
||||
var back UploadLimits
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatalf("re-unmarshal: %v", err)
|
||||
}
|
||||
if back != in {
|
||||
t.Errorf("round-trip mismatch: in=%+v back=%+v", in, back)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user