storage.upload v1.0.1
Presigned, direct-to-storage uploads and downloads for any S3-compatible provider, via in-part AWS Signature Version 4 — no SDK, no proxying bytes through the app.
provides storage.upload@1
Attestations
adapterdefault
verified2026-06-11
expires2026-06-25
conformance13 tests
signaturedev (pre-v0)
Invariants
Testable claims, not adjectives — each maps to at least one named conformance test.
- Importing the part performs no I/O and never throws; presigning is pure computation (no network), and configuration and inputs are validated at call time with typed errors
- Presigned signatures conform to AWS Signature Version 4 exactly: byte-for-byte identical to the canonical AWS implementation (AWS CLI / botocore) for the same inputs, across path-style and virtual-hosted addressing
- Each presigned request carries the required X-Amz-* query parameters with the correct credential scope (date/region/s3/aws4_request), host, SignedHeaders, and UNSIGNED-PAYLOAD; method is PUT for upload and GET for download
- The signature binds the whole request: changing the key, method, expiry, region, or secret changes the signature, so a presigned URL cannot be repurposed
- Object keys with spaces, Unicode, and S3-special characters are URI-encoded per the S3 rules so they sign and address correctly; empty, over-long, or control-character keys are rejected with a typed error and no output
- Expiry is bounded to 1..604800 seconds and reflected in both X-Amz-Expires and the returned expiresAt; an out-of-range expiry fails fast with a typed error
- The secret access key never appears in any URL, header, or error message; all failures surface as typed StorageError values
Interface
presignUpload(key: string, opts?: PresignUploadOptions): Promise<PresignedRequest>
presignDownload(key: string, opts?: PresignDownloadOptions): Promise<PresignedRequest>
class StorageError extends Error { code: StorageErrorCode }
types: PresignedRequest, PresignUploadOptions, PresignDownloadOptions, StorageErrorCode
Environment
STORAGE_ENDPOINT | required |
|---|---|
STORAGE_REGION | required |
STORAGE_BUCKET | required |
STORAGE_ACCESS_KEY_ID | required · secret |
STORAGE_SECRET_ACCESS_KEY | required · secret |
STORAGE_FORCE_PATH_STYLE | optional |
Seams — what your app writes
Sufficient without reading src/; that is part of the quality bar.
# Seams — storage.upload
What YOUR app provides. Reading `contract.json` + this file is enough to wire
the part — you never need to read `src/`. Never edit `src/` (attested
interior; edits void the attestation and fail CI).
## 1. Environment
The part holds your storage credentials and reads them lazily at call time.
`partkit add` scaffolds these into `.env.example`:
| Var | Required | Notes |
|---|---|---|
| `STORAGE_ENDPOINT` | yes | Base URL of the S3 API, e.g. `https://s3.us-east-1.amazonaws.com`, `https://<account>.r2.cloudflarestorage.com`, `https://minio.example.com:9000`. |
| `STORAGE_REGION` | yes | e.g. `us-east-1`. R2 uses `auto`. |
| `STORAGE_BUCKET` | yes | The bucket name. |
| `STORAGE_ACCESS_KEY_ID` | yes | Public key id (appears in the URL — not secret). |
| `STORAGE_SECRET_ACCESS_KEY` | yes | **Secret.** Stays on the server; never sent to the browser. |
| `STORAGE_FORCE_PATH_STYLE` | no | `true` (default) → `endpoint/bucket/key`; `false` → `bucket.endpoint/key`. MinIO needs `true`; AWS S3 works either way; R2 typically `false`. |
```ts
import { presignUpload, presignDownload, StorageError } from "@parts/storage.upload";
```
Never deep-import `src/internal/**` (lint-enforced).
## 2. The flow — presign on the server, transfer in the browser
The part **only signs URLs** — it never moves bytes and never calls the
network. The upload happens directly between the browser and your storage:
1. Browser asks your API for a presigned URL (`examples/upload-route.ts`).
2. Server calls `presignUpload(key)` and returns `{ url, method, headers }`.
3. Browser does `fetch(url, { method, headers, body: file })`
(`examples/browser-upload.ts`).
4. Browser tells your API the `key`; you store it against the record.
```ts
const { url, method, headers, expiresAt } = await presignUpload("uploads/u1/photo.jpg", {
expiresInSeconds: 300, // 1..604800, default 900
});
// later, to serve a private object:
const dl = await presignDownload("uploads/u1/photo.jpg");
```
`headers` is `{}` in v1 (only `host` is signed, which the browser sets itself)
— spread it anyway so future signed headers keep working.
## 3. Choosing the object key (YOUR responsibility)
You pick the key; the part signs it. Rules:
- **Derive keys server-side** from a trusted id — never let the client choose
the full path, or one user can presign over another's objects. Namespace by
user/tenant: `uploads/<userId>/<random>-<name>`.
- Keys may contain `/`, spaces, and Unicode (they are encoded for you).
Rejected: empty, a leading `/`, control characters, and keys over 1024 bytes
(a `StorageError` with code `invalid_key`).
## 4. CORS — the one provider-side setup
Because the browser uploads cross-origin to your storage host, the **bucket
must allow your site's origin** for `PUT` (and `GET` for downloads), exposing
no special headers. Set this once in your provider's CORS config — it is not
something the part can do for you. Symptom when missing: the `fetch` PUT fails
with a CORS error in the browser console while `presignUpload` itself succeeds.
## 5. Error handling
Every failure is a `StorageError` with `.code`:
- `config` — a missing/invalid `STORAGE_*` env var (your deploy is
misconfigured → treat as 500).
- `invalid_key` — see §3 (client's fault → 400).
- `invalid_options` — `expiresInSeconds` outside `1..604800`.
The secret key is scrubbed from every error message.
## 6. What you must NOT do
- Edit or import anything under `src/internal/**`.
- Send `STORAGE_SECRET_ACCESS_KEY` to the browser, or presign in client code.
- Let the client supply the raw object key (§3).
- Set very long expiries for sensitive objects — a presigned URL is a bearer
token for that one object until it expires; prefer minutes, not days.
Install
$ partkit add storage.upload