/** * The byte cap on R2 object transfer, and the arithmetic the routes need to * enforce it before they buffer anything. * * One number, one home (Task 1695). The storage-broker MCP tools and the house * ui routes both read the cap from here, so the process that rejects a too-large * file and the process that would otherwise buffer it cannot drift apart. * * Deliberately dependency-free: the MCP imports this module directly, without * the rest of the lib (cf-exec, registry, audit, remediate), which it has no use * for. The ui route reads it through the barrel. */ /** * The maximum size, in bytes, of an object moved through storage-r2-object-get * or storage-r2-object-put. * * Set independently of the email plugin's OUTBOUND_ATTACHMENT_MAX_BYTES (25 MiB) * even though the payload class is the same. The two exist for different reasons * and must be free to move for different reasons: email's tracks an SMTP-side * rejection threshold, this one tracks house-process memory. Do not couple them. * * Not bounded by R2. A single-part upload may be up to 5 GiB (doc-confirmed: * developers.cloudflare.com/r2/platform/limits/), roughly fifty times this, and * the v4 REST API states no separate object-size limit. This cap is the only * bound on either transfer. * * The bound is not small: at the cap a get still leaves the house process * holding the object and its base64 form, roughly 2.4x the object. That cost was * weighed and accepted when the number was chosen. */ export const R2_OBJECT_MAX_BYTES = 100 * 1024 * 1024; /** base64 emits 4 characters per 3 input bytes, padded up to the next quantum. */ export function base64Ceiling(bytes: number): number { return 4 * Math.ceil(bytes / 3); } /** * The largest JSON body an object route's metadata envelope can legitimately * carry: a bucket name, one key or prefix, and JSON punctuation. No payload term. * * Used twice, because it answers one question — how large can an envelope * legitimately get. `/r2/object/list`, `get` and `delete` carry an envelope and * nothing else, so this is their whole body limit (Task 1698). `put` carries the * same envelope plus base64 object bytes, so it adds this to the base64 ceiling * below, which is the allowance that keeps a legal at-cap put from being rejected * by its own key. * * Deliberately one constant rather than two that happen to match. This is the * opposite ruling to the email cap above, for the opposite reason: that pair must * stay independent because the numbers track different constraints and would drift * for different reasons. Here the constraint is identical, so a change to either * use must move both. * * Sized from confirmed R2 limits rather than assumed. An object key is at most * 1,024 bytes (developers.cloudflare.com/r2/platform/limits/) and a bucket name is * 3-63 characters (developers.cloudflare.com/r2/buckets/create-buckets/), so a * real envelope is about 1.1 KB, or roughly 6 KiB if every key byte takes a * six-character \uXXXX escape. This clears that by about ten times and sits about * 2,100 times below put's body limit: far above anything a real caller sends, far * below anything that threatens house memory. Nothing here depends on either * figure being exact. */ export const R2_OBJECT_ENVELOPE_MAX_BODY_BYTES = 64 * 1024; /** * The largest JSON body /r2/object/put can legitimately carry. * * This bounds house memory; it does not adjudicate the last kilobyte. The exact * decoded-byte rule lives in the MCP, which knows the source's size exactly. */ export const R2_OBJECT_PUT_MAX_BODY_BYTES = base64Ceiling(R2_OBJECT_MAX_BYTES) + R2_OBJECT_ENVELOPE_MAX_BODY_BYTES; function formatMiB(bytes: number): string { return (bytes / 1024 / 1024).toFixed(1); } /** * The over-cap message, worded once so the MCP and the routes agree. Names the * offending object and the limit, which is what makes the failure actionable * rather than an opaque out-of-memory kill. * * Exact bytes accompany the MiB figures because MiB alone rounds: an object one * byte over the cap reads "100.0 MiB, which exceeds the 100.0 MiB limit", which * is a message that appears to contradict itself and invites the reader to treat * the rejection as a bug. The bytes are what make it legible; the MiB is what * makes it quick. */ export function tooLargeMessage(what: string, actualBytes: number): string { return `${what} is ${formatMiB(actualBytes)} MiB (${actualBytes} bytes), which exceeds the ${formatMiB( R2_OBJECT_MAX_BYTES, )} MiB (${R2_OBJECT_MAX_BYTES} bytes) R2 object limit.`; }