Upload a file
How-to. Let a client upload a file directly to the store, then decide whether the application needs to validate and accept it. For the port's surface, see
@btravstack/storage.
The three steps
- The client tells you what it wants to write — the type, and how many bytes.
- You decide whether it may, and hand back a URL for that key, type and size.
- The client
PUTs straight at the store, then tells you it is done.
Nothing in step 3 goes through your process, which is the point: an upload that transits the application is a request held open for the length of a transfer, and a unit the drain has to wait on. The store already accepts writes directly, and a presigned URL is your time-limited permission to write a key. When the application must see the bytes as they pass — to encrypt or verify them before they are stored — stream them through the process instead.
1. Compose the starter
export const AttachmentsApp = Module("AttachmentsApp")({
imports: [storage({ adapter: s3Storage() })],
provides: [attachments],
exports: [Attachments],
});s3Storage() binds STORAGE_S3_ENDPOINT, STORAGE_S3_BUCKET, STORAGE_S3_ACCESS_KEY_ID and STORAGE_S3_SECRET_ACCESS_KEY (plus an optional STORAGE_S3_REGION) through Config, and holds one client as a resource of the graph. It works against any S3-compatible store — AWS, RustFS, MinIO, R2, B2 — and the endpoint is required rather than defaulted to AWS, because a default pointing at Amazon would be a surprising bill rather than a convenience. The two @aws-sdk packages are optional peers, reached only through the @btravstack/storage/s3 subpath.
2. Minting the URL
presignedUpload signs the key, the content type and the content length. A request with a different key, declared type or length is refused by the store. Without a sha256, the signature does not constrain which bytes fill that length. With one (lowercase hex, 64 characters), x-amz-checksum-sha256 is signed too: the client must send its base64 form, the store refuses a body that does not hash to it, and head later answers the stored digest.
For a tenant's encryption key, pass kmsKeyId to presignedUpload. The client must also send these exact signed headers on its PUT:
curl -X PUT --upload-file avatar.png \
-H 'content-type: image/png' \
-H 'x-amz-server-side-encryption: aws:kms' \
-H "x-amz-server-side-encryption-aws-kms-key-id: $KMS_KEY_ID" \
"$URL"Omitting or changing either encryption header breaks the signature. When both kmsKeyId and sha256 were supplied, the client sends all three x-amz-* headers. Retrieving an encrypted object's checksum with head also requires kms:GenerateDataKey and kms:Decrypt on that key alongside s3:GetObject. The storage reference covers write verification, server-side checksum copies, atomic metadata and the KMS test boundary.
It is time-limited, not single-use: until ttlMs runs out, the same URL can PUT that key again and replace the object with different bytes of the same size and type (with a sha256, bytes hashing to the signed digest). The signature does not prove that any upload happened.
class Attachments extends Port("Attachments")<{
readonly upload: (
tenantId: TenantId,
name: string,
file: { readonly contentType: string; readonly sizeBytes: number },
) => AsyncResult<string, TooLarge | UnsupportedType | InvalidStorageOptions | StorageUnavailable>;
readonly download: (
tenantId: TenantId,
name: string,
) => AsyncResult<string, StorageUnavailable>;
}> {}
const ONE_MEGABYTE = 1_024 * 1_024;
const keyFor = (tenantId: TenantId, name: string) =>
`attachments/${tenantId}/${name}`;
const attachments = Provider(Attachments)({
inject: { store: Storage },
sync: ({ store }) => ({
// Your policy, as steps rather than guard clauses: each `ensure` names
// the rule it enforces and passes the same file through. The URL cannot
// be widened afterwards — what you sign is what the store will accept.
upload: (tenantId, name, file) =>
OkAsync(file)
.ensure(
(candidate) => candidate.sizeBytes <= 5 * ONE_MEGABYTE,
() => new TooLarge({ key: keyFor(tenantId, name) }),
)
.ensure(
(candidate) => candidate.contentType.startsWith("image/"),
(candidate) =>
new UnsupportedType({ contentType: candidate.contentType }),
)
.flatMap((accepted) =>
store
.presignedUpload(keyFor(tenantId, name), {
ttlMs: 60_000,
contentType: accepted.contentType,
contentLength: accepted.sizeBytes,
})
// Folded HERE rather than at the end of the chain, so the
// application's own two failures never meet the adapter's.
.mapErrCases((matcher) =>
// The memory adapter cannot presign, so a graph composed
// without a real store fails here — loudly, in development.
matcher
.with(
P.tag("PresignNotSupported"),
() =>
new StorageUnavailable({
operation: "presignedUpload",
key: keyFor(tenantId, name),
reason: "this store cannot mint a url",
}),
)
.with(P.tag("StorageUnavailable"), (failure) => failure)
.with(P.tag("InvalidStorageOptions"), (failure) => failure),
),
),
// The same move in reverse: a time-limited read the client follows
// itself. Serving those bytes through your own handler instead is the
// anti-pattern this whole page exists to avoid.
download: (tenantId, name) =>
store
.presignedUrl(keyFor(tenantId, name), { ttlMs: 60_000 })
.mapErrCases((matcher) =>
matcher
.with(
P.tag("PresignNotSupported"),
() =>
new StorageUnavailable({
operation: "presignedUrl",
key: keyFor(tenantId, name),
reason: "this store cannot mint a url",
}),
)
.with(P.tag("StorageUnavailable"), (failure) => failure),
),
}),
});The client then writes the bytes itself:
curl -X PUT --upload-file avatar.png \
-H 'content-type: image/png' \
"$URL"When the URL was minted with a sha256, the client also sends the digest, base64-encoded, as a signed header:
curl -X PUT --upload-file avatar.png \
-H 'content-type: image/png' \
-H "x-amz-checksum-sha256: $(openssl dgst -sha256 -binary avatar.png | base64)" \
"$URL"Accepting the upload
The example signs the final key directly. Use that shape only when another write before the URL expires is acceptable, such as a replaceable attachment. A client's "done" call does not prove that an object exists or that its bytes are the ones you intend to accept.
When the contents or final state matter, mint the URL for a unique staging key per attempt. On confirmation, get that key, validate the actual bytes, then put those same bytes under a final key for which no upload URL was issued. Record acceptance in your application's state only after that write succeeds. A replay can change the staging object, but not the accepted final object through its URL. A missing staging object is ObjectNotFound; signed length and type alone do not validate its contents.
This confirmation moves the bytes through your process once. For files too large for that, use a store-native copy or another upload protocol in your application's infrastructure. The storage port does not choose that policy.
Reading it back
presignedUrl is the same move in reverse — the download arm above — and the client fetches the object directly. Both arms fold PresignNotSupported into StorageUnavailable, because an adapter that cannot sign is, from the application's point of view, a store it cannot use.
In development and in tests
memoryStorage() refuses to presign — both directions — rather than minting a file:// URL that would pass locally and fail in the deployment. So the presigned flow has no in-process double: exercise it against a real S3-compatible store. The repository's own suites run against the RustFS container in internal/test-infra, which pnpm dev starts too, so the local loop already has one.
A test that only needs the store to hold bytes still uses the memory adapter; it is the presign arms specifically that need something real behind them.