Design: Uniform File-Upload Support for smithy-hono Generated Services
0. Scope & summary
Today a service generated by this codegen can only accept a file as base64 embedded in a JSON body, fully buffered in memory, hashed whole for HMAC signing, and with nowhere to land the bytes (there is no object-storage port in any adapter). This document proposes a uniform upload abstraction — one Smithy trait + model convention, one generated route shape, and one ObjectStore storage-port — that lets a modeled operation stream an upload into R2 / S3 / local-or-S3 across the three deploy targets, while staying inside the existing security pipeline's body-size, content-type, signing, and authorization guarantees. No implementation is proposed here — design only.
1. Current limitations (with file:line evidence)
1.1 The codegen speaks JSON bodies only
- Body binding collapses to JSON.
InputBindingsmaps every body-bound member to eitherPAYLOAD(a member carrying@httpPayload) orIMPLICIT_BODY, andisBodyMethodonly recognizes POST/PUT/PATCH (InputBindings.java:43-57). There is no binding class for a raw/binary/multipart request body. - The validator chain is always
zValidator('json', …). InRouteEmitter.buildValidatorChain, both the@httpPayloadbranch and the implicit-body branch emit a JSON validator (RouteEmitter.java:256-267);buildPayloadValidatorhard-codeszValidator('json', …)(RouteEmitter.java:318-320). Input assembly likewise readsc.req.valid('json')(RouteEmitter.java:353-358). There is noc.req.parseBody()/formData()/ raw-stream path anywhere in the emitter. - A blob is validated as a base64 JSON string.
ZodEmitter.emitBlob(ZodEmitter.java:310-323) emitsz.string().regex(/^[A-Za-z0-9+/]*={0,2}$/)for a non-streaming blob — i.e. the file must be base64-encoded and wrapped in JSON (≈33% size inflation, and it is fully materialized twice: once as base64 text, once decoded). A@streamingblob degrades to an opaquez.string()passthrough (ZodEmitter.java:311-315) — it is typed as a stream but nothing inRouteEmitterever reads the request body as a stream, so streaming input is not actually representable at the route level. - Input-side
@streamingis not wired.ModelIndex.isStreamingonly returns true for the SSE response trait (ModelIndex.java:188-190,SseStreamTrait); it has no notion of a streaming request body. The metadata registry emitsstreaming: truesolely from that SSE trait (MetadataRegistryEmitter.java:123-124). - The generated client can only send JSON.
ClientEmitter.bodyExpralwaysJSON.stringify(...)(ClientEmitter.java:275-285) — it cannot postmultipart/form-data,application/octet-stream, or a stream.
Net: multipart and binary uploads are not representable. The only upload story is "base64 blob inside a JSON object," fully buffered, size-capped by the JSON body limit.
1.2 The security pipeline actively rejects real uploads
- Content-type allowlist → 415 for multipart/binary.
headerGuards/bodyGuardsrequire the request media type to equal the single modeled protocol content-type (restJson1 →application/json), else 415 (bodyGuards.ts:257,bodyGuards.ts:268-274). Amultipart/form-dataorapplication/octet-streamupload is rejected before the handler runs. - The whole body is buffered in memory, then replaced.
readBoundedBodyaccumulates every chunk into aUint8Arrayup tomaxBodyBytes(bodyGuards.ts:155-187) andrebuildBoundedRequestrebuilds theRequestaround those bytes (bodyGuards.ts:196-209). There is no streaming-to-storage path; peak memory ≈ file size, and any file overmaxBodyBytes(per-op or global default) is 413'd (bodyGuards.ts:234,bodyGuards.ts:321-330). This is correct and desirable for JSON APIs, but it is exactly wrong for large uploads. - HMAC signing hashes the entire body. The canonical string's field 5 is the SHA-256 of the exact received body bytes (
canonical.ts:49-52,canonical.ts:226-241), and the verifier obtains those bytes viareadRawBody(c)→c.req.arrayBuffer(), which buffers the whole body (rawBody.ts:113-118,verifySignature.ts:331,:359). For a signed upload the entire file must be read into memory and digested before auth completes — no streaming, and the buffering bound is only safe becausebodyGuardscapped it first (rawBody.ts:54-68).
1.3 There is no object-storage port anywhere
- The only data port is
DataStore<T>— row/entity semantics (get/create/put/update/patch/delete/list), not blobs (data-core/src/index.ts:97-149). - adapter-cf implements KV / Durable Objects / D1 (
adapter-cf/src/dataStore.ts,ports.ts), no R2. A repo-wide search forR2 / S3 / multipart / PutObject / ObjectStorereturns no production hits. - adapter-aws implements DynamoDB (
dynamoPort.ts,stores/), no S3. - adapter-node implements Redis/in-memory (
stores.ts,dataStore.ts), no filesystem/S3. - The CF deploy tool renders only
kv_namespaces,durable_objects,d1_databasesbindings (deploy-cf/src/wrangler.ts:64-108);BindingsSpechas nor2field (deploy-cf/src/config.ts:40-44). So even if bytes arrived, there is no provisioned bucket to hold them.
2. Proposed uniform upload abstraction
Three coordinated pieces: (A) a model convention, (B) a generated route shape, (C) an ObjectStore storage-port. The design goal is that the security pipeline is unchanged in spirit — the same size cap, an extended content-type allowlist, and a streaming-friendly body-hash mode — and that a handler author never touches raw bytes unless they opt in.
2.A Model convention: @upload trait + ObjectRef shape
Add a service-owned trait (mirroring the existing traits/ package, e.g. RequiresAuthTrait, SseStreamTrait):
// model/traits.smithy (new)
@trait(selector: "operation")
structure upload {
/// wire framing the route accepts for the payload member
mode: UploadMode // "binary" (octet-stream) | "multipart" (form-data)
/// hard byte ceiling for the streamed payload (drives per-op maxBodyBytes)
maxBytes: Long
/// content-type allowlist for the stored object (validated, not trusted)
accept: ContentTypeList
/// logical bucket/namespace the ObjectStore writes into
store: String
}
The upload payload member is modeled as a @streaming blob under @httpPayload for the binary mode, or as a dedicated multipart part in multipart mode. The operation's input keeps all its other members as ordinary path/query/header bindings (unchanged), and its output returns an ObjectRef:
structure ObjectRef {
@required key: String // opaque storage key the service assigned
@required size: Long
@required contentType: String
checksumSha256: String // hex; ties into signing (§4)
etag: String
}
This keeps uploads first-class in the model (discoverable, versioned, documented) rather than an out-of-band side channel. It composes with @requiresAuth, @readonly, pagination, etc., because it only changes how the payload member is transported.
2.B Generated route shape
When RouteEmitter.emitRoute sees @upload, it emits a different body branch instead of the JSON validator (new branch alongside RouteEmitter.java:256-267):
- No JSON body validator for the payload member. Path/query/header validators are emitted exactly as today (
RouteEmitter.java:244-255) — those still validate. - A generated upload preamble that:
- reads the payload as a bounded stream (binary:
c.req.raw.body; multipart:c.req.parseBody()/formData()for the file part), - validates the declared content-type against
@upload.accept(defense-in-depth, since the pipeline allowlist is widened for this route — see §4), - calls the injected
ObjectStore.put(...)port, obtaining anObjectRef, - assembles
inputwith the payload member replaced by theObjectRef(or the store handle), so the handler receives a reference, not bytes.
- reads the payload as a bounded stream (binary:
- The
MetadataRegistryEmittergains anuploaddescriptor and a per-opconstraints.maxBodyBytes = @upload.maxBytes(extending the existingconstraintsemission atMetadataRegistryEmitter.java:93,:134), so the pipeline's per-op body cap and the content-type widening are driven by the model — no hand-editing.
The handler interface (RouteEmitter.emitOperationsInterface, RouteEmitter.java:67-78) is unchanged in shape: the operation still takes a typed input and returns a typed output; only the payload member's TS type becomes ObjectRef/an upload handle rather than a base64 string.
2.C Storage-port interface (data-core)
Add an ObjectStore port next to DataStore (data-core/src/index.ts), following the established narrow-structural-port convention (ARCH-01) so no adapter imports a cloud SDK into the type surface:
export interface ObjectStore {
/** Stream bytes to storage under `scope`; returns the assigned ref. */
put(
key: string,
body: ReadableStream<Uint8Array> | Uint8Array,
meta: { contentType: string; contentLength?: number; checksumSha256?: string },
scope: DataScope,
): Promise<ObjectRef>
/** Open a stored object for download (streamed back out). */
get(key: string, scope: DataScope): Promise<ObjectBody | null>
delete(key: string, scope: DataScope): Promise<boolean>
/** Optional: presigned direct-to-storage upload/download URL (capability-graded). */
presign?(
key: string,
op: 'put' | 'get',
opts: { expiresInSeconds: number; contentType?: string },
scope: DataScope,
): Promise<string>
}
Reusing DataScope (data-core/src/index.ts:35) means uploads inherit the same tenant-isolation key-prefixing that DataStore already enforces (tenant A cannot address tenant B's objects). presign? is optional and capability-graded exactly like DataStore.count? (data-core/src/index.ts:145-149) — some backends (S3, R2) support it, others (local FS) do not.
3. Per-target implementation sketch
Each adapter implements ObjectStore behind its own narrow *Like port, mirroring how dataStore.ts sits behind D1DatabaseLike/KvNamespaceLike (adapter-cf/src/ports.ts).
3.1 Cloudflare — R2 (adapter-cf)
- New
R2BucketLikestructural port ({ put, get, delete, createMultipartUpload? }) that a consumer's realR2Bucketbinding satisfies without importing@cloudflare/workers-types(same discipline asKvNamespaceLike,ports.ts:30-38). createR2ObjectStore(bucket: R2BucketLike)implementsObjectStore.putby passing the boundedReadableStreamstraight tobucket.put(key, stream, { httpMetadata, sha256 }). R2 accepts a stream, so once the pipeline widens to allow streamed bodies (§4), bytes flow edge→R2 without full buffering.presign→ R2 presigned URLs.- deploy-cf gains an
r2?: R2BindingSpec[]field onBindingsSpec(deploy-cf/src/config.ts:40-44) and a[[r2_buckets]]render block inwrangler.tsalongside the existing kv/DO/d1 blocks (wrangler.ts:64-108), plus provisioning inbin/deploy.ts.
3.2 AWS — S3 via Lambda (adapter-aws)
- New
S3ClientLikestructural port (putObject/getObject/deleteObject/createPresignedPost), satisfied by the AWS SDK v3 client the consumer already injects (same pattern asdynamoPort.ts). createS3ObjectStore(client, bucket)implementsput. Caveat: API Gateway + Lambda buffers and base64-encodes the request body (documented inrawBody.ts:76), so true streaming-to-S3 is not available on that path for the payload itself. The recommended AWS mode is therefore presigned PUT (ObjectStore.presign('put', …)): the modeled operation returns a presigned URL and the client uploads directly to S3, bypassing the Lambda body limit entirely. Small uploads may still go through the buffered Lambda path intoputObject. This tradeoff should be surfaced in@uploaddocs.
3.3 Node / k8s — local FS or S3 (adapter-node)
- Two implementations behind the same port:
createFsObjectStore(rootDir)(streams to a temp file, fsync, rename — atomic; nopresign) for single-node/dev, andcreateS3ObjectStore(...)reused from a shared S3 helper for production k8s (MinIO/S3-compatible). Node's@hono/node-serverexposes a real WebReadableStreambody (rawBody.tsruntime table), so streaming-to-disk/S3 works without the Lambda buffering constraint.
All three land bytes via the same ObjectStore.put call the generated route emits — the adapter is the only thing that varies.
4. Composition with body-size, content-type, signing, and auth
This is the crux: uploads must not punch a hole in the pipeline.
- Body-size limits.
@upload.maxBytesbecomes the operation'sconstraints.maxBodyBytes, which the pipeline already reads per-op (bodyGuards.ts:234,resolveLimit). So an upload route gets a higher, explicit, modeled cap while every other route keeps the global default. The during-read cap inreadBoundedBody(bodyGuards.ts:155-187) still applies — an upload over its ownmaxBytesis 413'd. The change needed: allow the bounded stream to be forwarded toObjectStorerather than only buffered-then-parsed. Cleanest approach: for upload routes,bodyGuardsenforces the byte cap during read but skips the JSON parse + structural walk (bodyGuards.ts:339-356), which are meaningless for binary, and hands the bounded stream/bytes to the route. - Content-type. The single-value allowlist (
bodyGuards.ts:257,:271) must become per-op: for an@uploadroute the accepted media types are@upload.accept(binary →application/octet-stream; multipart →multipart/form-data), resolved from the metadata registry the same waymaxBodyBytesalready is. Non-upload routes are unchanged (still restJson1application/json). The route-level re-validation in §2.B is defense-in-depth. - HMAC body-hash signing. The canonical contract hashes exact body bytes (
canonical.ts:49-52). Two paths:- Small/buffered uploads (Lambda path, or files under a "sign-inline" threshold): unchanged —
readRawBodybuffers and the existing verifier works as-is (verifySignature.ts:331). - Large/streamed uploads: buffering the whole file just to sign it defeats streaming. Proposal: for
@uploadroutes, sign over the client-computedX-SH-Body-Sha256verified incrementally — the pipeline streams the body through a running SHA-256 while forwarding chunks toObjectStore, and rejects (401) if the final digest ≠ the signedbodySha256Hex. This preserves the "never trust the client-declared hash" rule (canonical.ts:50-52) without a second full-body buffer. TheObjectRef.checksumSha256returned to the handler is that same verified digest. Presigned-URL uploads (AWS/CF) move the bytes out of the signed request entirely: the modeled request (which returns the presigned URL) is a normal JSON request signed the ordinary way; the actual bytes go direct-to-storage under the presign's own auth. This is the recommended posture for anything large.
- Small/buffered uploads (Lambda path, or files under a "sign-inline" threshold): unchanged —
- Auth / authorization. No change to ordering.
@requiresAuthstill drivesauthenticate+ the op-tierauthorize(OPERATIONS.x)hook, whichRouteEmitteralready emits after validators and before the handler (RouteEmitter.java:208-216). For streamed uploads the ordering must be: size-cap + content-type gate → authenticate/verifySignature → then stream toObjectStore, so an unauthenticated caller never causes a write. Becauseauthorizeruns before the handler and the handler is what receives theObjectRef, authZ is enforced before the bytes are durably committed (or, for presign, before a URL is minted).
5. Backwards-compatibility notes
- Purely additive at the model layer. Operations without
@uploadare byte-for-byte unchanged:InputBindingsstill routes@httpPayload/implicit members to the JSON branch (InputBindings.java:43-53),ZodEmitter.emitBlobstill emits base64/streaming string schemas (ZodEmitter.java:310-323), and the pipeline still enforcesapplication/json-only. Existing generated snapshots do not churn. - Pipeline config is additive.
ValidationConfig(bodyGuards.ts:45-55) gains no required field; the per-op content-type/maxBytes come from the metadata registry, and absent an@uploadop the resolved allowlist is exactly today's singleprotocolContentType.SecurityConfigis untouched for non-upload deployments. ObjectStoreis an opt-in injected port, exactly likeDataStore/AuditSink/SecretProvider(ARCH-05). A service with no upload operation never constructs one; adapters ship it as a new named export, not a breaking change to existing exports (adapter-cf/src/index.ts).- Signing contract is preserved, not forked. The default (buffered) path uses the unchanged canonical string. The streamed path produces the same
bodySha256Hexfield — it only changes how that digest is obtained (incrementally), so old clients/signers still interoperate on non-upload routes and on small uploads. The canonicalization spec (plan/security/07a-canonicalization-spec.md, referenced atcanonical.ts:9) would get an addendum, not a version bump, for the streamed-hash mode. - Generated client gains an upload overload (multipart/binary/presigned-follow) only for
@uploadops; the JSONbodyExprpath (ClientEmitter.java:275-285) is untouched for everything else.
6. Phased rollout
- Phase U0 — Port + convention (no wire change). Land
ObjectStore+ObjectRefindata-core, the conformance suite (mirroringdata-core/src/conformance.ts), and an in-memory fake. No codegen change. Ships value immediately: handlers can hand-wire uploads against a stable port. - Phase U1 — Node/local first. Implement
createFsObjectStore+createS3ObjectStoreinadapter-nodeand run them through conformance. Node has the friendliest streaming model (rawBody.tsruntime table), so it de-risks the streaming semantics before touching edge/serverless. - Phase U2 —
@uploadtrait + codegen (binary mode, buffered). Add the trait intraits/, theUPLOADbinding branch inInputBindings/RouteEmitter, and per-opmaxBodyBytes+ content-type widening inMetadataRegistryEmitter. Start with buffered binary uploads so the existing signing path (readRawBody) works unchanged. Golden-file/snapshot tests for the new route shape. - Phase U3 — Pipeline streaming + incremental hash. Extend
bodyGuardsto forward the bounded stream and skip JSON parse for upload routes, and add the streamed SHA-256 verification path toverifySignature. This is the highest-risk change (touches the signing contract) and is gated behind U2's tests. - Phase U4 — Cloudflare R2.
R2BucketLike+createR2ObjectStoreinadapter-cf,r2binding indeploy-cfconfig/wrangler.ts/provisioning. Validate on miniflare (there is already alive.miniflaretest convention inadapter-cf). - Phase U5 — AWS S3 + presigned.
S3ClientLike+createS3ObjectStoreinadapter-aws, and the presigned-PUT mode end-to-end (the recommended large-upload posture given the API Gateway/Lambda buffering limit noted atrawBody.ts:76). Addpresignto the CF and Node stores for parity. - Phase U6 — Client + docs. Generated-client upload overloads (multipart/binary/presign-follow) and a canonicalization-spec addendum for the streamed-hash mode.
Key files this design touches (for implementers)
- Codegen:
src/main/java/com/smithyhono/writers/{InputBindings,HttpBinding,RouteEmitter,ZodEmitter,MetadataRegistryEmitter,ClientEmitter}.java,src/main/java/com/smithyhono/ModelIndex.java, new trait undersrc/main/java/com/smithyhono/traits/. - Pipeline:
packages/security-core/src/pipeline/bodyGuards.ts,packages/security-core/src/signing/{rawBody,canonical,verifySignature}.ts,packages/security-core/src/config.ts. - Port:
packages/data-core/src/index.ts(+conformance.ts). - Adapters:
packages/adapter-cf/src/{ports,index}.ts,packages/adapter-aws/src/{port,index}.ts,packages/adapter-node/src/{ports,index}.ts. - Deploy:
packages/deploy-cf/src/{config,wrangler,bin/deploy}.ts.