Groundfloor Docs

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 row

Presigned URLs expire after 3600 seconds (1 hour) by default.


Permissions

OperationPermission
List files, get download URLread
Upload (init + finalize)write
Deletedelete

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
}
FieldRequiredNotes
nameYesDisplay name (1–255 chars). Not the bucket key — server assigns object_key.
content_typeNoMIME type for the PUT and stored metadata
size_bytesNoHint 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.pdf

From 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".

StatusMeaning
200Finalized (or idempotent if already active)
409Bucket object missing — PUT failed or expired URL
410Row 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

APIPurpose
/v1/workspaces/{id}/files/*Workspace files — user documents, attachments
/v1/workspaces/{id}/apps/{app_id}/releases/...App publishremoteEntry.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 -d

Add 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/groundfloor

MinIO console: http://localhost:9001 (minioadmin / minioadmin).


Errors

StatusMeaning
401 / 403Auth or permission
404Unknown file id or wrong workspace
409 on finalizeObject not in bucket
409 on downloadFile not active (still pending or deleted)

On this page