diff --git a/apps/workers-bindings/src/bindings.app.ts b/apps/workers-bindings/src/bindings.app.ts index 9cccf327..76721240 100644 --- a/apps/workers-bindings/src/bindings.app.ts +++ b/apps/workers-bindings/src/bindings.app.ts @@ -18,6 +18,7 @@ import { registerDocsTools } from '@repo/mcp-common/src/tools/docs-ai-search.too import { registerHyperdriveTools } from '@repo/mcp-common/src/tools/hyperdrive.tools' import { registerKVTools } from '@repo/mcp-common/src/tools/kv_namespace.tools' import { registerR2BucketTools } from '@repo/mcp-common/src/tools/r2_bucket.tools' +import { registerR2ObjectTools } from '@repo/mcp-common/src/tools/r2_object.tools' import { registerWorkersTools } from '@repo/mcp-common/src/tools/worker.tools' import { MetricsTracker } from '@repo/mcp-observability' @@ -79,6 +80,7 @@ export class WorkersBindingsMCP extends McpAgent + uploaded: string + storageClass: string +} + +/** + * Result of an R2 object GET operation + */ +export interface R2ObjectGetResult { + metadata: R2ObjectMetadata + content: string + isBase64: boolean +} + +/** + * Fetches an R2 object content and metadata + */ +export async function fetchR2ObjectGet({ + accountId, + bucketName, + objectKey, + apiToken, + jurisdiction, + maxSizeBytes, +}: { + accountId: string + bucketName: string + objectKey: string + apiToken: string + jurisdiction?: string + maxSizeBytes?: number +}): Promise { + const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}` + + const headers: Record = { + Authorization: `Bearer ${getApiToken(apiToken)}`, + } + + if (jurisdiction) { + headers['cf-r2-jurisdiction'] = jurisdiction + } + + const response = await fetch(url, { + method: 'GET', + headers, + }) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + const error = await response.text() + throw new Error(`R2 GET request failed: ${error}`) + } + + const metadata = parseR2ObjectMetadata(objectKey, response.headers) + + // Check size limit + if (maxSizeBytes && metadata.size > maxSizeBytes) { + throw new Error( + `Object size (${metadata.size} bytes) exceeds maximum allowed size (${maxSizeBytes} bytes)` + ) + } + + // Get content and determine if it should be base64 encoded + const contentType = metadata.httpMetadata.contentType || 'application/octet-stream' + const isTextContent = isTextContentType(contentType) + + let content: string + let isBase64: boolean + + if (isTextContent) { + content = await response.text() + isBase64 = false + } else { + const arrayBuffer = await response.arrayBuffer() + content = arrayBufferToBase64(arrayBuffer) + isBase64 = true + } + + return { metadata, content, isBase64 } +} + +/** + * Uploads an R2 object + */ +export async function fetchR2ObjectPut({ + accountId, + bucketName, + objectKey, + apiToken, + content, + jurisdiction, + storageClass, + contentType, + contentEncoding, + contentDisposition, + contentLanguage, + cacheControl, + expires, +}: { + accountId: string + bucketName: string + objectKey: string + apiToken: string + content: BodyInit + jurisdiction?: string + storageClass?: string + contentType?: string + contentEncoding?: string + contentDisposition?: string + contentLanguage?: string + cacheControl?: string + expires?: string +}): Promise<{ key: string; uploaded: string }> { + const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}` + + const headers: Record = { + Authorization: `Bearer ${getApiToken(apiToken)}`, + } + + if (jurisdiction) { + headers['cf-r2-jurisdiction'] = jurisdiction + } + if (storageClass) { + headers['cf-r2-storage-class'] = storageClass + } + if (contentType) { + headers['Content-Type'] = contentType + } + if (contentEncoding) { + headers['Content-Encoding'] = contentEncoding + } + if (contentDisposition) { + headers['Content-Disposition'] = contentDisposition + } + if (contentLanguage) { + headers['Content-Language'] = contentLanguage + } + if (cacheControl) { + headers['Cache-Control'] = cacheControl + } + if (expires) { + headers['Expires'] = expires + } + + const response = await fetch(url, { + method: 'PUT', + headers, + body: content, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`R2 PUT request failed: ${error}`) + } + + return { + key: objectKey, + uploaded: new Date().toISOString(), + } +} + +/** + * Deletes an R2 object + */ +export async function fetchR2ObjectDelete({ + accountId, + bucketName, + objectKey, + apiToken, + jurisdiction, +}: { + accountId: string + bucketName: string + objectKey: string + apiToken: string + jurisdiction?: string +}): Promise { + const url = `${CLOUDFLARE_API_BASE_URL}/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}` + + const headers: Record = { + Authorization: `Bearer ${getApiToken(apiToken)}`, + } + + if (jurisdiction) { + headers['cf-r2-jurisdiction'] = jurisdiction + } + + const response = await fetch(url, { + method: 'DELETE', + headers, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`R2 DELETE request failed: ${error}`) + } + + const result = (await response.json()) as { + success: boolean + errors?: Array<{ code: number; message: string }> + } + + if (!result.success) { + const errorMessage = result.errors?.[0]?.message ?? 'Unknown error' + throw new Error(errorMessage) + } + + return result +} + +/** + * Parse R2 object metadata from response headers + */ +function parseR2ObjectMetadata(objectKey: string, headers: Headers): R2ObjectMetadata { + const customMetadata: Record = {} + + // Extract custom metadata from x-amz-meta-* headers + headers.forEach((value, key) => { + if (key.toLowerCase().startsWith('x-amz-meta-')) { + const metaKey = key.slice('x-amz-meta-'.length) + customMetadata[metaKey] = value + } + }) + + return { + key: objectKey, + size: parseInt(headers.get('content-length') || '0', 10), + etag: headers.get('etag') || '', + httpMetadata: { + contentType: headers.get('content-type') || undefined, + contentEncoding: headers.get('content-encoding') || undefined, + contentDisposition: headers.get('content-disposition') || undefined, + contentLanguage: headers.get('content-language') || undefined, + cacheControl: headers.get('cache-control') || undefined, + expires: headers.get('expires') || undefined, + }, + customMetadata, + uploaded: headers.get('last-modified') || new Date().toISOString(), + storageClass: headers.get('x-amz-storage-class') || 'Standard', + } +} + +/** + * Check if a content type is text-based + */ +function isTextContentType(contentType: string): boolean { + const textTypes = [ + 'text/', + 'application/json', + 'application/xml', + 'application/javascript', + 'application/typescript', + 'application/x-www-form-urlencoded', + 'application/xhtml+xml', + 'application/x-yaml', + 'application/yaml', + 'application/toml', + 'application/graphql', + 'application/ld+json', + 'application/manifest+json', + 'application/schema+json', + 'application/sql', + 'application/x-sh', + ] + + const lowerContentType = contentType.toLowerCase() + return textTypes.some((type) => lowerContentType.startsWith(type)) +} + +/** + * Convert ArrayBuffer to base64 string + */ +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +/** + * Convert base64 string to Uint8Array + */ +export function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} diff --git a/packages/mcp-common/src/tools/r2_bucket.tools.ts b/packages/mcp-common/src/tools/r2_bucket.tools.ts index b440d1a2..19b33a81 100644 --- a/packages/mcp-common/src/tools/r2_bucket.tools.ts +++ b/packages/mcp-common/src/tools/r2_bucket.tools.ts @@ -8,7 +8,11 @@ import { BucketListNameContainsParam, BucketListStartAfterParam, BucketNameSchema, + CreateBucketJurisdictionSchema, + CreateBucketStorageClassSchema, + LocationHintSchema, } from '../types/r2_bucket.types' +import { BucketJurisdictionSchema } from '../types/r2_object.types' import { PaginationPerPageParam } from '../types/shared.types' export function registerR2BucketTools(agent: CloudflareMcpAgent) { @@ -71,8 +75,15 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { agent.server.tool( 'r2_bucket_create', - 'Create a new r2 bucket in your Cloudflare account', - { name: BucketNameSchema }, + `Create a new R2 bucket in your Cloudflare account. + You can optionally specify locationHint (geographic region), jurisdiction (regulatory), and storageClass (default storage class for new objects). + Note: locationHint cannot be used with jurisdictions.`, + { + name: BucketNameSchema, + locationHint: LocationHintSchema, + jurisdiction: CreateBucketJurisdictionSchema, + storageClass: CreateBucketStorageClassSchema, + }, { title: 'Create R2 bucket', annotations: { @@ -80,17 +91,33 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { destructiveHint: false, }, }, - async ({ name }) => { + async ({ name, locationHint, jurisdiction, storageClass }) => { const account_id = await agent.getActiveAccountId() if (!account_id) { return MISSING_ACCOUNT_ID_RESPONSE } + + // Validate mutual exclusivity - locationHint can be paired with 'default' jurisdiction but not 'eu' or 'fedramp' + if (locationHint && jurisdiction && jurisdiction !== 'default') { + return { + content: [ + { + type: 'text', + text: `Error: Cannot specify locationHint with '${jurisdiction}' jurisdiction. locationHint can only be used with 'default' jurisdiction or no jurisdiction specified.`, + }, + ], + } + } + try { const props = getProps(agent) const client = getCloudflareClient(props.accessToken) const bucket = await client.r2.buckets.create({ account_id, name, + locationHint: locationHint ?? undefined, + jurisdiction: jurisdiction ?? undefined, + storageClass: storageClass ?? undefined, }) return { content: [ @@ -116,14 +143,14 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { agent.server.tool( 'r2_bucket_get', 'Get details about a specific R2 bucket', - { name: BucketNameSchema }, + { name: BucketNameSchema, bucketJurisdiction: BucketJurisdictionSchema }, { title: 'Get R2 bucket', annotations: { readOnlyHint: true, }, }, - async ({ name }) => { + async ({ name, bucketJurisdiction }) => { const account_id = await agent.getActiveAccountId() if (!account_id) { return MISSING_ACCOUNT_ID_RESPONSE @@ -131,7 +158,10 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { try { const props = getProps(agent) const client = getCloudflareClient(props.accessToken) - const bucket = await client.r2.buckets.get(name, { account_id }) + const bucket = await client.r2.buckets.get(name, { + account_id, + jurisdiction: bucketJurisdiction ?? undefined, + }) return { content: [ { @@ -156,7 +186,7 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { agent.server.tool( 'r2_bucket_delete', 'Delete an R2 bucket', - { name: BucketNameSchema }, + { name: BucketNameSchema, bucketJurisdiction: BucketJurisdictionSchema }, { title: 'Delete R2 bucket', annotations: { @@ -164,7 +194,7 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { destructiveHint: true, }, }, - async ({ name }) => { + async ({ name, bucketJurisdiction }) => { const account_id = await agent.getActiveAccountId() if (!account_id) { return MISSING_ACCOUNT_ID_RESPONSE @@ -172,7 +202,10 @@ export function registerR2BucketTools(agent: CloudflareMcpAgent) { try { const props = getProps(agent) const client = getCloudflareClient(props.accessToken) - const result = await client.r2.buckets.delete(name, { account_id }) + const result = await client.r2.buckets.delete(name, { + account_id, + jurisdiction: bucketJurisdiction ?? undefined, + }) return { content: [ { diff --git a/packages/mcp-common/src/tools/r2_object.tools.ts b/packages/mcp-common/src/tools/r2_object.tools.ts new file mode 100644 index 00000000..f769ee92 --- /dev/null +++ b/packages/mcp-common/src/tools/r2_object.tools.ts @@ -0,0 +1,326 @@ +import mime from 'mime' + +import { MISSING_ACCOUNT_ID_RESPONSE } from '../constants' +import { getProps } from '../get-props' +import { + base64ToUint8Array, + fetchR2ObjectDelete, + fetchR2ObjectGet, + fetchR2ObjectPut, +} from '../r2-api' +import { type CloudflareMcpAgent } from '../types/cloudflare-mcp-agent.types' +import { + Base64EncodedSchema, + BucketJurisdictionSchema, + BucketNameSchema, + CacheControlSchema, + ContentDispositionSchema, + ContentEncodingSchema, + ContentLanguageSchema, + ContentTypeSchema, + ExpiresSchema, + MAX_OBJECT_SIZE_BYTES, + MAX_UPLOAD_SIZE_BYTES, + ObjectContentSchema, + ObjectKeySchema, + StorageClassSchema, +} from '../types/r2_object.types' + +export function registerR2ObjectTools(agent: CloudflareMcpAgent) { + /** + * Tool to get an R2 object content and metadata + */ + agent.server.tool( + 'r2_object_get', + `Download an object from an R2 bucket. + Returns the object content and metadata. + - Images are returned using MCP's native image type for direct viewing. + - Text content (text/*, application/json, etc.) is returned as plain text. + - Other binary content is returned as base64-encoded string. + Maximum object size: ${MAX_OBJECT_SIZE_BYTES / 1024 / 1024}MB. + Returns null if the object does not exist.`, + { + bucket: BucketNameSchema, + key: ObjectKeySchema, + bucketJurisdiction: BucketJurisdictionSchema, + }, + { + title: 'Get R2 object', + annotations: { + readOnlyHint: true, + }, + }, + async ({ bucket, key, bucketJurisdiction }) => { + const account_id = await agent.getActiveAccountId() + if (!account_id) { + return MISSING_ACCOUNT_ID_RESPONSE + } + + try { + const props = getProps(agent) + const result = await fetchR2ObjectGet({ + accountId: account_id, + bucketName: bucket, + objectKey: key, + apiToken: props.accessToken, + jurisdiction: bucketJurisdiction ?? undefined, + maxSizeBytes: MAX_OBJECT_SIZE_BYTES, + }) + + if (!result) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ exists: false, key }), + }, + ], + } + } + + const contentType = result.metadata.httpMetadata.contentType || 'application/octet-stream' + const metadataText = JSON.stringify({ + exists: true, + metadata: result.metadata, + }) + + // Images: use MCP's native image type + if (contentType.startsWith('image/')) { + return { + content: [ + { + type: 'text', + text: metadataText, + }, + { + type: 'image', + data: result.content, + mimeType: contentType, + }, + ], + } + } + + // Text content: return inline + if (!result.isBase64) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + exists: true, + metadata: result.metadata, + content: result.content, + }), + }, + ], + } + } + + // Other binary: return base64 with metadata + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + exists: true, + metadata: result.metadata, + content: result.content, + isBase64: true, + note: 'Binary content is base64-encoded. Use wrangler r2 object get or the Cloudflare dashboard to download.', + }), + }, + ], + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error getting R2 object: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + } + } + } + ) + + /** + * Tool to upload an object to an R2 bucket + */ + agent.server.tool( + 'r2_object_put', + `Upload an object to an R2 bucket. Maximum upload size: ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB. + + Content-Type is auto-detected from the object key extension if not specified. + + To upload a local file: + 1. Read the file content (use your file reading capability) + 2. For text files: pass the content directly as a string + 3. For binary files (images, PDFs, etc.): base64-encode the content and set base64Encoded to true + + For files larger than ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB, use wrangler r2 object put.`, + { + bucket: BucketNameSchema, + key: ObjectKeySchema, + content: ObjectContentSchema, + base64Encoded: Base64EncodedSchema, + contentType: ContentTypeSchema, + contentEncoding: ContentEncodingSchema, + contentDisposition: ContentDispositionSchema, + contentLanguage: ContentLanguageSchema, + cacheControl: CacheControlSchema, + expires: ExpiresSchema, + storageClass: StorageClassSchema, + bucketJurisdiction: BucketJurisdictionSchema, + }, + { + title: 'Upload R2 object', + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + }, + async ({ + bucket, + key, + content, + base64Encoded, + contentType, + contentEncoding, + contentDisposition, + contentLanguage, + cacheControl, + expires, + storageClass, + bucketJurisdiction, + }) => { + const account_id = await agent.getActiveAccountId() + if (!account_id) { + return MISSING_ACCOUNT_ID_RESPONSE + } + + try { + const props = getProps(agent) + + // Decode base64 content if specified + let bodyContent: BodyInit + if (base64Encoded) { + bodyContent = base64ToUint8Array(content) + } else { + bodyContent = content + } + + // Check size limit + const contentSize = + typeof bodyContent === 'string' ? bodyContent.length : bodyContent.byteLength + if (contentSize > MAX_UPLOAD_SIZE_BYTES) { + return { + content: [ + { + type: 'text', + text: `Error: Content size (${Math.round(contentSize / 1024 / 1024)}MB) exceeds maximum upload size (${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB). Use wrangler r2 object put for larger files.`, + }, + ], + } + } + + // Auto-detect content type from key extension if not provided + const detectedContentType = contentType ?? mime.getType(key) ?? undefined + + const result = await fetchR2ObjectPut({ + accountId: account_id, + bucketName: bucket, + objectKey: key, + apiToken: props.accessToken, + content: bodyContent, + jurisdiction: bucketJurisdiction ?? undefined, + storageClass: storageClass ?? undefined, + contentType: detectedContentType, + contentEncoding: contentEncoding ?? undefined, + contentDisposition: contentDisposition ?? undefined, + contentLanguage: contentLanguage ?? undefined, + cacheControl: cacheControl ?? undefined, + expires: expires ?? undefined, + }) + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Object uploaded successfully to ${bucket}/${key}`, + key: result.key, + contentType: detectedContentType ?? 'application/octet-stream', + uploaded: result.uploaded, + }), + }, + ], + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error uploading R2 object: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + } + } + } + ) + + /** + * Tool to delete an object from an R2 bucket + */ + agent.server.tool( + 'r2_object_delete', + `Delete an object from an R2 bucket.`, + { + bucket: BucketNameSchema, + key: ObjectKeySchema, + bucketJurisdiction: BucketJurisdictionSchema, + }, + { + title: 'Delete R2 object', + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async ({ bucket, key, bucketJurisdiction }) => { + const account_id = await agent.getActiveAccountId() + if (!account_id) { + return MISSING_ACCOUNT_ID_RESPONSE + } + try { + const props = getProps(agent) + const result = await fetchR2ObjectDelete({ + accountId: account_id, + bucketName: bucket, + objectKey: key, + apiToken: props.accessToken, + jurisdiction: bucketJurisdiction ?? undefined, + }) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result), + }, + ], + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error deleting R2 object: ${error instanceof Error && error.message}`, + }, + ], + } + } + } + ) +} diff --git a/packages/mcp-common/src/types/r2_bucket.types.ts b/packages/mcp-common/src/types/r2_bucket.types.ts index 33e4bc20..66f9dc50 100644 --- a/packages/mcp-common/src/types/r2_bucket.types.ts +++ b/packages/mcp-common/src/types/r2_bucket.types.ts @@ -62,6 +62,28 @@ export const BucketListStartAfterParam = z .optional() .describe('Bucket name to start searching after. Buckets are ordered lexicographically.') +// Location hint for bucket creation - suggests geographic region for the bucket +export const LocationHintSchema = z + .enum(['wnam', 'enam', 'weur', 'eeur', 'apac', 'oc']) + .optional() + .describe( + 'Geographic location hint: wnam=Western North America, enam=Eastern North America, weur=Western Europe, eeur=Eastern Europe, apac=Asia Pacific, oc=Oceania' + ) + +// Jurisdiction for bucket creation - specifies regulatory jurisdiction +export const CreateBucketJurisdictionSchema = z + .enum(['default', 'eu', 'fedramp']) + .optional() + .describe('Jurisdiction for the bucket. Use for data residency requirements.') + +// Storage class for bucket creation - sets default storage class for new objects +export const CreateBucketStorageClassSchema = z + .enum(['Standard', 'InfrequentAccess']) + .optional() + .describe( + 'Default storage class for newly uploaded objects. Standard is for frequently accessed data, InfrequentAccess is for data accessed less than once per month (lower storage cost, higher retrieval cost).' + ) + export const AllowedMethodsEnum: z.ZodType = z.array( z.union([ z.literal('GET'), @@ -74,7 +96,7 @@ export const AllowedMethodsEnum: z.ZodType = z .enum(['default', 'eu', 'fedramp']) .describe( - 'Use Jurisdictional Restrictions when you need to ensure data is stored and processed within a jurisdiction to meet data residency requirements, including local regulations such as the GDPR or FedRAMP.' + 'Use Jurisdictional Restrictions when you need to ensure data is stored and processed within a jurisdiction to meet data residency requirements, including local regulations such as the EU or FedRAMP.' ) // CORS ZOD SCHEMAS diff --git a/packages/mcp-common/src/types/r2_object.types.ts b/packages/mcp-common/src/types/r2_object.types.ts new file mode 100644 index 00000000..c0b920f6 --- /dev/null +++ b/packages/mcp-common/src/types/r2_object.types.ts @@ -0,0 +1,82 @@ +/** + * This file contains the validators for the R2 object tools. + */ +import { z } from 'zod' + +import { BucketNameSchema } from './r2_bucket.types' + +// Re-export bucket name for convenience +export { BucketNameSchema } + +// Jurisdiction for object operations - only needed when buckets share the same name across jurisdictions +export const BucketJurisdictionSchema = z + .enum(['default', 'eu', 'fedramp']) + .optional() + .describe( + 'Only needed if you have multiple buckets with the same name in different jurisdictions.' + ) + +// Object key schemas +export const ObjectKeySchema = z + .string() + .min(1) + .describe('The key (path) of the object in the bucket') + +export const ObjectKeysSchema = z + .array(z.string().min(1)) + .min(1) + .max(1000) + .describe('Array of object keys to delete (max 1000)') + +// Storage and content schemas +export const StorageClassSchema = z + .enum(['Standard', 'InfrequentAccess']) + .optional() + .describe('Storage class') + +export const ContentTypeSchema = z + .string() + .optional() + .describe('MIME type of the object (e.g., "text/plain", "image/png")') + +export const ContentEncodingSchema = z + .string() + .optional() + .describe('Content encoding (e.g., "gzip", "br")') + +export const ContentDispositionSchema = z + .string() + .optional() + .describe('Content disposition (e.g., "attachment; filename=example.txt")') + +export const ContentLanguageSchema = z + .string() + .optional() + .describe('Content language (e.g., "en-US")') + +export const CacheControlSchema = z + .string() + .optional() + .describe('Cache control header (e.g., "max-age=3600")') + +export const ExpiresSchema = z + .string() + .optional() + .describe('Expiration date in RFC 2822 or ISO 8601 format') + +// Content for upload +export const ObjectContentSchema = z + .string() + .describe('The content of the object (text or base64-encoded)') + +export const Base64EncodedSchema = z + .boolean() + .optional() + .default(false) + .describe('If true, the content is base64-encoded and will be decoded before upload') + +// Size limit for get operations (10MB) +export const MAX_OBJECT_SIZE_BYTES = 10 * 1024 * 1024 + +// Size limit for upload operations (100MB) +export const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0566af7d..67d05568 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,6 +1101,9 @@ importers: hono: specifier: 4.7.6 version: 4.7.6 + mime: + specifier: 4.0.6 + version: 4.0.6 toucan-js: specifier: 4.1.1 version: 4.1.1 @@ -8372,7 +8375,7 @@ snapshots: flatted: 3.3.3 pathe: 2.0.3 sirv: 3.0.1 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 tinyrainbow: 2.0.0 vitest: 3.0.9(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.0.9)(lightningcss@1.29.2)(tsx@4.19.3) @@ -9599,9 +9602,9 @@ snapshots: define-data-property@1.1.4: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-properties@1.2.1: dependencies: @@ -10457,7 +10460,7 @@ snapshots: has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 has-proto@1.0.3: {} @@ -11687,8 +11690,8 @@ snapshots: define-data-property: 1.1.4 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 has-property-descriptors: 1.0.2 set-function-name@2.0.2: