Files
Upload, download, and manage workspace-scoped files via Control Plane.
Audience: Federated app developers
Base path: /v1/workspaces/{workspace_id}/files
Auth: Keycloak bearer token + workspace permissions
Storage: S3-compatible object store (MinIO local, S3/R2/GCS in prod)
Bytes never flow through Control Plane. The API owns metadata, ReBAC, and presigned URL minting. The client uploads and downloads directly against the bucket.
Lifecycle
1. POST /files/upload-url → pending row + presigned PUT URL
2. Client PUT → bytes go directly to bucket
3. POST /files/{id}/finalize → HEAD object, promote row to active
4. GET /files/{id}/download-url → presigned GET URL
5. DELETE /files/{id} → remove object + soft-delete rowPresigned URLs expire after 3600 seconds (1 hour) by default.
Permissions
| Operation | Permission |
|---|---|
| List files, get download URL | read |
| Upload (init + finalize) | write |
| Delete | delete |
List files
GET /v1/workspaces/{workspace_id}/files
Authorization: Bearer {token}Returns up to 200 active files, newest first.
{
"files": [
{
"id": "f8a3…",
"workspace_id": "42e6…",
"object_key": "workspaces/42e6…/files/f8a3…",
"name": "report.pdf",
"content_type": "application/pdf",
"size_bytes": 1048576,
"etag": "\"abc123\"",
"status": "active",
"uploaded_by": "user-uuid",
"created_at": "2026-05-19T12:00:00Z",
"updated_at": "2026-05-19T12:00:05Z"
}
]
}Only status: "active" files appear in the list. pending rows exist between upload-url and finalize.
Upload a file
Step 1 — Request upload URL
POST /v1/workspaces/{workspace_id}/files/upload-url
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "report.pdf",
"content_type": "application/pdf",
"size_bytes": 1048576
}| Field | Required | Notes |
|---|---|---|
name | Yes | Display name (1–255 chars). Not the bucket key — server assigns object_key. |
content_type | No | MIME type for the PUT and stored metadata |
size_bytes | No | Hint for UI progress; verified on finalize via bucket HEAD |
{
"file": {
"id": "f8a3…",
"status": "pending",
"name": "report.pdf",
…
},
"upload_url": "http://localhost:9000/groundfloor/workspaces/…?X-Amz-…",
"expires_in_s": 3600
}Step 2 — PUT bytes to bucket
curl -X PUT "${upload_url}" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdfFrom the browser (same pattern as Customer Portal):
const putResp = await fetch(initData.upload_url, {
method: "PUT",
body: file,
headers: file.type ? { "Content-Type": file.type } : undefined,
});
if (!putResp.ok) throw new Error(`Bucket PUT failed: ${putResp.status}`);CORS: For browser uploads, the object store must allow PUT from your Shell origin. Local MinIO typically allows this when OBJECT_STORE_PRESIGN_ENDPOINT matches the URL the browser uses.
Step 3 — Finalize
POST /v1/workspaces/{workspace_id}/files/{file_id}/finalize
Authorization: Bearer {token}Server HEADs the bucket object, captures real size_bytes, content_type, and etag, then sets status: "active".
| Status | Meaning |
|---|---|
200 | Finalized (or idempotent if already active) |
409 | Bucket object missing — PUT failed or expired URL |
410 | Row was deleted |
Download a file
GET /v1/workspaces/{workspace_id}/files/{file_id}/download-url
Authorization: Bearer {token}{
"download_url": "http://localhost:9000/groundfloor/…?X-Amz-…",
"expires_in_s": 3600
}Open in a new tab or fetch and save — URL is short-lived.
const { download_url } = await getDownloadUrl(workspaceId, fileId);
window.open(download_url, "_blank", "noopener,noreferrer");Delete a file
DELETE /v1/workspaces/{workspace_id}/files/{file_id}
Authorization: Bearer {token}Soft-deletes metadata (status: "deleted") and best-effort removes the bucket object. Idempotent if already deleted.
Object key namespacing
All keys are server-controlled:
workspaces/{workspace_id}/files/{file_id}Clients cannot choose or override prefixes — prevents cross-tenant object access even if presigned URLs leak.
TypeScript example (full upload flow)
Mirrors apps/customer/src/app/(app)/files/page.tsx:
import {
useRequestUploadUrlV1WorkspacesWorkspaceIdFilesUploadUrlPost,
useFinalizeUploadV1WorkspacesWorkspaceIdFilesFileIdFinalizePost,
useListFilesV1WorkspacesWorkspaceIdFilesGet,
} from "@groundfloor/api-client";
async function uploadFile(workspaceId: string, file: File) {
const init = await requestUpload.mutateAsync({
workspaceId,
data: {
name: file.name,
content_type: file.type || null,
size_bytes: file.size,
},
});
const put = await fetch(init.upload_url, {
method: "PUT",
body: file,
headers: file.type ? { "Content-Type": file.type } : undefined,
});
if (!put.ok) throw new Error(`PUT ${put.status}`);
await finalizeUpload.mutateAsync({
workspaceId,
fileId: init.file.id,
});
}Federated app publish vs workspace files
| API | Purpose |
|---|---|
/v1/workspaces/{id}/files/* | Workspace files — user documents, attachments |
/v1/workspaces/{id}/apps/{app_id}/releases/... | App publish — remoteEntry.js bundles for Module Federation |
Do not confuse workspace file storage with app release upload (different routes and key prefixes).
Local dev setup
docker compose -f deploy/docker-compose.phase2-deps.yml --profile minio up -dAdd to Control Plane .env:
OBJECT_STORE_BACKEND=s3
OBJECT_STORE_ENDPOINT=http://localhost:9000
OBJECT_STORE_BUCKET=groundfloor
OBJECT_STORE_ACCESS_KEY=minioadmin
OBJECT_STORE_SECRET_KEY=minioadmin
OBJECT_STORE_FORCE_PATH_STYLE=true
OBJECT_STORE_PRESIGN_ENDPOINT=http://localhost:9000
OBJECT_STORE_PUBLIC_BASE_URL=http://localhost:9000/groundfloorMinIO console: http://localhost:9001 (minioadmin / minioadmin).
Errors
| Status | Meaning |
|---|---|
401 / 403 | Auth or permission |
404 | Unknown file id or wrong workspace |
409 on finalize | Object not in bucket |
409 on download | File not active (still pending or deleted) |
Related
- 04-data-vault.md — structured customer data (not binary blobs)
- 03-authentication.md
- 11-local-dev-recipes.md — curl upload script