CreateUploadSession does not receive the file bytes. It registers a PendingUpload asset and grants an authorized caller a temporary PUT capability. The client then sends bytes directly to object storage so the API process does not proxy large payloads.
1. Request and response contract
POST /api/files/upload-session HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "ownerType": "user", "fileName": "invoice-2026-07.pdf", "contentType": "application/pdf", "size": 48231, "visibility": "private"}The server generates fileId and storageKey; presignedUrl is a temporary credential:
{ "fileId": "019c64e67fc87db8b7ca748f5442f91a", "storageKey": "tenants/tenant-a/019c64e67fc87db8b7ca748f5442f91a", "presignedUrl": "https://storage.example/presigned-put-token"}The response has no explicit expiry. S3 uses the handler’s 30-minute TTL. The connector bridge ignores this argument because its provider owns UploadUrlExpiration.
2. Trusted and untrusted fields
| Field | Provider | Handler behavior |
|---|---|---|
| TenantId | current user | Blank or 0 returns File.TenantRequired |
| OwnerId | current UserId / ClientId | Neither returns File.OwnerRequired |
| FileId | server | UUID v7 in 32-character N format |
| StorageKey | server | tenants/{tenantId}/{fileId} |
| OwnerType | client | Non-empty and 50-character bound only |
| Visibility | client | Non-empty and 20-character bound only |
| FileName | client | Not part of the key; max 255 characters |
| ContentType | client | Stored as the later comparison baseline |
| Size | client | Must be positive; no platform maximum |
OwnerId currently means “the uploading user or client,” not an order, ticket, or conversation. Business ownership still needs a separate attachment relation. OwnerType=document does not bind a document.
3. Processing order
The URL is signed before the database Save. If Save fails, a client may still hold a usable storage capability without a corresponding FileAsset. Production needs compensating deletion, short TTL, isolated prefixes, and object-inventory reconciliation.
4. Complete .NET client flow
public async Task<string> UploadInvoiceAsync( HttpClient api, HttpClient objectStorage, byte[] pdf, CancellationToken cancellationToken){ // Files receives metadata only. TenantId and OwnerId come from the bearer identity. using var createResponse = await api.PostAsJsonAsync( "/api/files/upload-session", new { ownerType = "user", fileName = "invoice-2026-07.pdf", contentType = "application/pdf", size = pdf.LongLength, visibility = "private" }, cancellationToken);
createResponse.EnsureSuccessStatusCode(); var session = await createResponse.Content .ReadFromJsonAsync<UploadSessionDto>(cancellationToken);
// The presigned URL carries storage authorization. Never forward the API bearer token. using var put = new HttpRequestMessage(HttpMethod.Put, session!.PresignedUrl) { Content = new ByteArrayContent(pdf) }; put.Content.Headers.ContentType = new("application/pdf");
using var uploadResponse = await objectStorage.SendAsync(put, cancellationToken); uploadResponse.EnsureSuccessStatusCode();
// Return FileId only. A business relation must wait for successful finalize. return session.FileId;}
public sealed record UploadSessionDto( string FileId, string StorageKey, string PresignedUrl);Do not persist or log the full presigned URL. Its query usually carries signature scope and expiry data.
5. Browser direct upload
type UploadSession = { fileId: string; storageKey: string; presignedUrl: string;};
export async function createAndPut(file: File): Promise<UploadSession> { // Send untrusted metadata only; cookie/bearer context establishes the principal. const sessionResponse = await fetch("/api/files/upload-session", { method: "POST", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ ownerType: "user", fileName: file.name, contentType: file.type || "application/octet-stream", size: file.size, visibility: "private", }), });
if (!sessionResponse.ok) throw new Error(`create session failed: ${sessionResponse.status}`);
const session = (await sessionResponse.json()) as UploadSession; // Send only storage-approved headers; never forward application authentication. const uploadResponse = await fetch(session.presignedUrl, { method: "PUT", headers: { "content-type": file.type || "application/octet-stream" }, body: file, });
// Do not finalize after a failed PUT. Recreate instead of reusing an unknown URL. if (!uploadResponse.ok) throw new Error(`object upload failed: ${uploadResponse.status}`);
return session;}Browser upload requires storage CORS to allow the application Origin, PUT, and required headers. CORS is a browser policy, not a replacement for Files authorization or a private bucket.
6. Validation and errors
| Condition | Current error/behavior | Client action |
|---|---|---|
| Size ≤ 0 | File.InvalidSize | Fix input; do not retry |
| No trusted tenant | File.TenantRequired | Fix authentication/tenant resolution |
| No UserId or ClientId | File.OwnerRequired | Use a supported principal |
| Missing/oversized metadata | File.MetadataRequired/TooLong | Fix input |
| Provider cannot presign | File.UploadSessionUnavailable | Do not PUT or finalize |
| Database Save fails | Repository error | Service must compensate the already signed target |
| Storage PUT times out | Object state is unknown | HEAD or create a fresh session |
The command does not implement IIdempotentRequest and accepts no idempotency key. Retrying a timed-out create produces a new FileAsset and key.
7. File name and content type
The original file name is display/download metadata only. The server key is independent, and a test deliberately accepts ../file.pdf. The name remains unsafe:
- HTML-encode it in UI;
- sanitize Content-Disposition and support an RFC 5987 UTF-8 form;
- length-limit structured log fields;
- never infer MIME or content from the extension;
- define normalization and duplicate-name policy in the business owner.
The current S3 download adapter removes only quotes, CR, and LF. The connector bridge does not pass the file name at all.
8. Target upload-admission policy
Add a centralized policy before signing a URL:
public sealed record FileUploadIntent( string TenantId, string SubjectId, string OwnerType, string FileName, string DeclaredContentType, long DeclaredSize);
public interface IFileUploadAdmissionPolicy{ // Return normalized MIME, a byte ceiling, and the required checksum algorithm. // Reject before URL issuance so a denied intent never receives a storage capability. Task<Result<ApprovedUpload>> AuthorizeAsync( FileUploadIntent intent, CancellationToken cancellationToken);}
public sealed record ApprovedUpload( string ContentType, long MaximumBytes, string ChecksumAlgorithm);This is a target extension contract; it is not in the current source. It should combine plan quota, OwnerType registry, MIME allowlist, risk control, and audit.
9. Production hardening checklist
- Keep the bucket private; key guessing must not grant anonymous reads.
- Constrain presigned PUT by length, type, and SHA-256.
- Rate-limit by tenant, subject, IP, and concurrent sessions.
- Persist a one-time session identity and consumption state.
- Enforce UploadExpiresAt during finalize.
- Compute the content hash in a trusted service.
- Upload into a quarantine prefix and promote only after scanning.
- Revoke/delete a signed target when session persistence fails.
- Monitor the difference between issued, uploaded, finalized, and cleaned assets.
- Never retain a full presigned URL in logs, events, or frontend state stores.
10. Verification
# Run create-session handler tests, including server keys and fail-closed behavior.dotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~CreateUploadSession
# Prove the current lack of size ceilings and expiry enforcement.rg -n "Max.*File|Quota|UploadExpiresAt" src/Platform/Files src/Hosts -g '*.cs'
# Inspect signed PUT conditions; current S3 code has only bucket/key/verb/expiry.rg -n "GetPreSignedUrlRequest|ContentLength|Checksum|ContentType" \ src/Framework/BitzOrcas.Infrastructure.Storage.S3Compatible -g '*.cs'