Answers, read off the code rather than from memory. Short version: browser-direct upload does not work today, and the design rastrillo can offer as its default is proxy-through-handler. The gap is real but small, and closing it is a decision about tenant authority that I have not made for you — the two shapes are at the bottom.
1. Can an app set a CORS policy on its granted bucket? No.
carlos store grant mints exactly one inline policy, and this is the whole of it (internal/objstore/aws.go:117-133, storePolicyJSON):
s3:ListBucket on <bucket> s3:GetObject, s3:PutObject, s3:DeleteObject on <bucket>/*
That is the complete action list. No s3:PutBucketCors, no s3:GetBucketCors. It is the only place in the tree that builds a store-grant policy document.
And the platform does not set one either. EnsureBucket (internal/objstore/aws.go:172) calls s3.CreateBucket and nothing else — no PutBucketCors anywhere in objstore, no carlos store sub-verb for it, no console door, and nothing in infra/. A case-insensitive sweep for cors across the repo returns only HTTP-preflight comments in the console and Vary: Origin handling in the edge cache. Unrelated.
So a granted bucket carries no CORS configuration at all, and the credential cannot add one. A browser PUT to a presigned URL fails preflight before it sends a byte, exactly as you suspected. PresignPut is unusable from a page today.
2. If uploads proxy, what are the ingress limits?
Better than I expected, and the answer is "no ceiling of ours, but one edge case".
- Body size: no cap on the app path.
Edge.ServeHTTPhands the request to the proxy/activator with nohttp.MaxBytesReaderanywhere in that path. The 32 MBmaxStoreBodyBytes(internal/edge/storedoor.go:47) is the fleet-box deployment-store door, a different path, and does not apply to your instance. - Duration: no cap either, deliberately. Both servers set only
ReadHeaderTimeout: 10sand noReadTimeout/WriteTimeout— the comment atinternal/edge/edge.go:2334says why: those would break long-lived proxied WebSockets. Once the headers are read, the edge does not time your body out. - An open request does hold the instance awake, and the hibernation sweep cannot take it. The idle sweep requires
e.inflight == 0before it will hibernate (internal/activator/activator.go:973), andlastActiveis stamped when the request finishes. So the slow-upload shape is safe in the way the scheduled-work contract is. - The one thing that can cut a long upload: build convergence. When a route's live symlink moves, the activator cycles the instance even with a request in flight (
internal/activator/activator.go:1002), draining for at most 20s (actStopTimeout). A multi-minute upload crossing a deploy is cut exactly as a restart would cut it. Worth designing for — resumable or chunked, or accept that a deploy can interrupt an in-progress upload.
3. Presigned PUT on the S3 bridge: not exercised, either verb.
The platform never presigns. Every hit for presign in the tree is a comment saying so deliberately — internal/edge/storedoor.go:58 records it as an F8 design decision to stream bytes through the edge rather than redirect to a presigned URL. So nothing in this repo has ever put a presigned GET or PUT through the bridge, and a PUT-specific incompatibility would not have surfaced here. rastrillo's local SigV4 signing is, as far as this repo is concerned, untested against the bridge in both directions.
What would close it — Paul's call, not mine
Two shapes, and they differ in who holds the authority rather than in difficulty:
- Widen the grant: add
s3:PutBucketCors/s3:GetBucketCorstostorePolicyJSON. The app then manages its own bucket's CORS with the credential it already holds. It is three words of diff. It also means an app can open its own bucket to any origin, which is within its own tenancy but is a real widening of what a store grant means. - A platform verb:
carlos store cors --app <a> --origin https://..., the platform writing the configuration and the credential staying exactly as narrow as it is. More code, one more door to keep in step, and the allowed origins become a thing the platform can see and audit.
I lean to (2), because the grant is the security boundary and every other widening of it has been a deliberate operator act rather than a capability an app grants itself — but (1) is defensible and much smaller, and the difference only matters if a compromised app credential turning its own bucket world-readable-from-any-origin is a threat worth pricing.
Leaving this open on that decision. Say which and I will build it.
Triaged 2026-09-02 as part of a sweep of the open list; investigation is read-only, no code changed.