File Uploads

Grove provides a built-in file upload system with presigned URLs, content-type validation, size limits, and transparent encryption for PII-classified fields. Files are stored in S3 (production) or the local filesystem (development).

Prerequisites: API Routes, Field Classifications. What you'll learn: How to declare File fields, upload and download files via the web layer, handle PII-encrypted files, and understand the storage backend.

The File Type

Grove has a first-class File type for record fields. Declare it with optional content_types and max_size constraints:

record {
  kind "invc"
  title: String

  invoice: File {
    content_types: ["application/pdf", "image/*"]
    max_size: 25mb
  }

  medical_scan: File {
    content_types: ["image/dicom"]
    max_size: 500mb
  } @pii
}
ConstraintDescription
content_typesArray of allowed MIME types. Supports wildcards (image/*).
max_sizeMaximum file size. Accepts units like kb, mb, gb.
@piiMarks the field as PII -- uploads and downloads are encrypted with a per-subject data encryption key.

When a client attempts to upload a file that violates these constraints, the server rejects the request before any bytes reach storage.

Upload Flow (Presigned URL)

The standard upload is a two-step process: obtain a presigned PUT URL, then upload directly to S3.

Step 1 -- Get a Presigned URL

POST /_file/presign-put
Content-Type: application/json

{
  "module": "invoices",
  "record_id": "inv_abc123",
  "field_name": "invoice",
  "filename": "receipt.pdf",
  "content_type": "application/pdf"
}

Response:

{
  "url": "https://s3.amazonaws.com/bucket/...",
  "key": "proj_1/invoices/a3/inv_abc123/invoice/b7e2...a3.pdf",
  "encrypted": false,
  "expires_at": "2026-04-08T12:05:00Z"
}

The url is a time-limited presigned PUT URL. The key is the object key that identifies this file in storage. The encrypted flag indicates whether the file requires server-side encryption (always false for non-PII fields).

Step 2 -- Upload Directly to S3

PUT <presigned url>
Content-Type: application/pdf

<file bytes>

The client uploads directly to S3 using the presigned URL. No file data passes through the Grove server.

Multipart Upload

For cases where a direct S3 upload is not practical (e.g., server-side processing, or you need a BLAKE3 hash), use the multipart form upload endpoint. The file passes through the Grove server, which computes a BLAKE3 integrity hash.

POST /_file/upload
Content-Type: multipart/form-data

module=invoices
record_id=inv_abc123
field_name=invoice
file=@receipt.pdf

Response:

{
  "key": "proj_1/invoices/a3/inv_abc123/invoice/b7e2...a3.pdf",
  "hash": "b7e2...a3",
  "size": 204800,
  "content_type": "application/pdf"
}

The hash field is a BLAKE3 digest that can be used for deduplication or integrity verification.

Download Flow

To download a file, obtain a presigned GET URL and redirect or fetch from it.

POST /_file/presign-get
Content-Type: application/json

{
  "key": "proj_1/invoices/a3/inv_abc123/invoice/b7e2...a3.pdf"
}

Response:

{
  "url": "https://s3.amazonaws.com/bucket/...",
  "encrypted": false
}

Redirect the user's browser to the url, or fetch it server-side. The presigned GET URL is time-limited.

PII-Classified Files

File fields annotated with @pii are encrypted with a per-subject data encryption key (DEK). Because the server must encrypt and decrypt the file contents, presigned URLs cannot be used. Instead, uploads and downloads go through dedicated server-side endpoints.

Encrypted Upload

PUT /_file/enc-upload/proj_1/scans/c4/rec_xyz/medical_scan/d91f...c4.dcm

<file bytes>

The server encrypts the file with the subject's DEK before writing it to storage.

Encrypted Download

GET /_file/enc-download/proj_1/scans/c4/rec_xyz/medical_scan/d91f...c4.dcm

The server retrieves the encrypted file from storage, decrypts it with the subject's DEK, and streams the plaintext back to the client.

How to Tell Which Flow to Use

The encrypted flag in the presign responses tells you:

encryptedUpload endpointDownload endpoint
falsePresigned PUT URL (direct to S3)Presigned GET URL (direct from S3)
truePUT /_file/enc-upload/{key}GET /_file/enc-download/{key}

When encrypted is true, the presigned URL fields are empty -- use the enc-upload/enc-download paths instead.

Storage Backends

Production -- S3

In production, files are stored in S3. Presigned URLs allow clients to upload and download without file data passing through the application server (except for PII-encrypted fields).

Development -- Local Filesystem

When running manzano dev, files are stored on the local filesystem at .dev/blobs/. Grove proxies local file operations through two development-only endpoints:

PUT /_file/local/{*key}
GET /_file/local/{*key}

These endpoints are only available in development mode. In production, they return 404.

Object Key Layout

Files are stored using a deterministic key structure:

{project_id}/{module}/{hash[-2:]}/{record_id}/{field_name}/{hash}.{ext}
SegmentDescription
project_idThe Grove project identifier
moduleThe module that owns the record
hash[-2:]Last two characters of the BLAKE3 hash (for sharding)
record_idThe record the file belongs to
field_nameThe field name on the record
hash.extFull BLAKE3 hash with the original file extension

Example:

proj_1/invoices/a3/inv_abc123/invoice/b7e2f9d1...a3.pdf

The two-character hash prefix (a3) distributes objects across key prefixes to avoid S3 hot-partition issues.

Endpoint Reference

EndpointMethodPurpose
/_file/presign-putPOSTGet a presigned PUT URL for direct S3 upload
/_file/presign-getPOSTGet a presigned GET URL for download
/_file/uploadPOSTMultipart form upload through the server (computes BLAKE3 hash)
/_file/enc-upload/{*key}PUTEncrypted upload for @pii fields
/_file/enc-download/{*key}GETEncrypted download for @pii fields
/_file/local/{*key}PUT/GETLocal filesystem proxy (development only)

See Also