Files API
⚠️ Draft above / implemented contract below. Sections 1–7 of this file predate the Phase-9 implementation and describe the proposed contract (
POST /files,POST /files/:fileId/complete, rawkeyin the file object,GET /files?mimeType=…). The implemented API differs (endpoints, response envelope, error codes). The authoritative, implementation-verified contract is §8 (TASK-0103 addendum) and §9 (TASK-0104 addendum) below — written againstapps/api/src/modules/files/*+apps/api/src/lib/authorize.tsand verified by the integration suites (tests/integration/files.integration.test.ts,files-lifecycle.integration.test.ts). The draft is kept for reference only; the conflicts are recorded indocs/tasks/phase-09-files.md(Phase 9 Notes).
File upload and download go through presigned object-storage URLs: the
API mints a short-lived PUT URL for the client to upload to directly, then
the client calls complete, which triggers the processing pipeline
(→ virus scan → metadata extraction → thumbnail generation →
file.uploaded event). Downloads are served via short-lived signed GET
URLs.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission note: uploading requires EDIT on the anchoring block/page
(or the workspace root); downloading requires VIEW on the file's
workspace plus file-level permission overrides when set. Public share links
can expose files without auth (see pages sharing).
File object:
{
"id": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"blockId": "blk_01J9ZKQ6D3E5F7A9B1C3D5E7F9",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"name": "architecture-v2.pdf",
"key": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"url": "https://factory.example.com/files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"size": 482113,
"mimeType": "application/pdf",
"status": "READY",
"metadata": {
"pageCount": 14,
"hashSha256": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"
},
"thumbnailKey": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/thumbs/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4.png",
"thumbnailUrl": "https://factory.example.com/files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4/thumb",
"scanReport": { "engine": "clamav-1.3.2", "result": "clean", "finishedAt": "2025-01-02T10:21:04.113Z" },
"uploadedBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"version": 1,
"createdAt": "2025-01-02T10:20:58.000Z",
"updatedAt": "2025-01-02T10:21:05.410Z"
}
status lifecycle:
UPLOADING → SCANNING → READY
├─────► VIRUS_DETECTED (file hidden, not downloadable)
└─────► FAILED (pipeline error)
1. Upload
1.1 POST /files
Create an upload session. Returns a presigned PUT URL (valid 15 minutes,
single use) and the fileId. The file record exists immediately with
status: "UPLOADING" — until complete succeeds it is not searchable or
downloadable, and an abandoned upload is garbage-collected after 24 h.
- Auth: JWT or API key (scope
WRITE).
Request:
{
"name": "architecture-v2.pdf",
"size": 482113,
"mimeType": "application/pdf",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"blockId": "blk_01J9ZKQ6D3E5F7A9B1C3D5E7F9",
"fileId": null
}
| Field | Required | Notes |
|---|---|---|
name |
yes (1–255) | Display name (extension is parsed from it if no mimeType). |
size |
yes (int > 0) | Must match the uploaded object exactly; mismatch on complete → 422. |
mimeType |
yes | Must pass the allow-list (§5 Limits); unknown/application/octet-stream is accepted but always scanned and blocked for web-served types. |
pageId |
no | Anchor page (for permission inheritance). |
blockId |
no | Anchor block (implies its page). |
fileId |
no | When set: this is a new version of an existing file — see §4 Versions. |
201 Created
{
"data": {
"fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"uploadUrl": "https://factory-media.example.com/bucket/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1%2Ffil_01J9ZKP3D8F0A2B4C6D8E0F2A4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=…&X-Amz-Date=20250102T102058Z&X-Amz-Expires=900&X-Amz-Signature=…",
"method": "PUT",
"headers": { "Content-Type": "application/pdf" },
"expiresAt": "2025-01-02T10:35:58.000Z",
"maxBytes": 482113
}
}
Upload with curl:
curl -s -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @architecture-v2.pdf
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing/bad fields. |
| 403 | FORBIDDEN |
No EDIT on anchor; MIME blocked by allow-list (details: [{field: "mimeType", issue: "mime_not_allowed"}]). |
| 404 | NOT_FOUND |
Anchor page/block invisible. |
| 413 | PAYLOAD_TOO_LARGE |
size exceeds per-type or plan limit (details: [{maxBytes}]). |
| 409 | CONFLICT |
fileId targets a file you cannot write, or the file was deleted. |
Events emitted: none yet (the file is not complete). file.upload_session_created is audit-logged only.
1.2 POST /files/:fileId/complete
Call after the PUT succeeds (even if the PUT partially failed — the worker verifies object size/hash). Triggers the pipeline:
complete → SCANNING (ClamAV) → metadata (mime, dimensions, pages, duration, sha256)
→ thumbnail/preview → READY → file.uploaded event
- Auth: JWT or API key (scope
WRITE). Same principal that created the session (orEDITon the anchor).
Request (optional):
{
"expectedSha256": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"
}
Providing expectedSha256 makes the worker verify content integrity; a
mismatch yields status: "FAILED", error.code: "HASH_MISMATCH".
202 Accepted (pipeline is async — poll §1.3):
{
"data": {
"fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"status": "SCANNING",
"stage": "virus_scan",
"updatedAt": "2025-01-02T10:21:00.215Z"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Object not present in storage yet (details: [{issue: "object_not_found"}]). |
| 404 | NOT_FOUND |
Unknown file id. |
| 409 | CONFLICT |
Upload session expired (> 24 h old) or already completed. |
| 422 | UNPROCESSABLE_ENTITY |
Size mismatch vs. declared size (details: [{issue: "size_mismatch", expected, actual}]). |
Events emitted: file.uploaded when the pipeline reaches READY
(async — not on this call's return).
1.3 GET /files/:fileId — Status & Metadata
- Auth: JWT or API key (scope
READ).VIEW.
200 OK (in flight):
{
"data": {
"id": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4",
"name": "architecture-v2.pdf",
"size": 482113,
"mimeType": "application/pdf",
"status": "SCANNING",
"stage": "thumbnail",
"progress": 0.8,
"createdAt": "2025-01-02T10:20:58.000Z",
"updatedAt": "2025-01-02T10:21:03.902Z"
}
}
When READY: the full file object (top of this doc) with metadata,
thumbnailUrl, scanReport. When VIRUS_DETECTED: scanReport.result: "infected", scanReport.threats: ["Win.Troj.Agent.gen"], and no
url/thumbnailUrl (downloads return 403). When FAILED:
error: { code: "PIPELINE_FAILED", message }.
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
2. List Files
GET /files
- Auth: JWT or API key (scope
READ).
Query params: cursor, limit (default 20, max 100), pageId, blockId,
mimeType (e.g. image/*), name (substring), status (default: hides
VIRUS_DETECTED/FAILED), createdBy. Ordered createdAt DESC.
200 OK — { data: [ <file objects> ], nextCursor }.
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 400 VALIDATION_ERROR
(bad mimeType pattern).
3. Download & Delete
3.1 GET /files/:fileId/download
Returns a 302 redirect to a short-lived (30 min, single use) signed URL.
The File object's url is a stable proxy path that performs the same
redirect, so client code can use either.
- Auth: JWT or API key (scope
READ).VIEW. - Query:
?download=trueforcesContent-Disposition: attachmentwith the original filename; default is inline.
HTTP/1.1 302 Found
Location: https://factory-media.example.com/bucket/…?X-Amz-Expires=1800&X-Amz-Signature=…
Content-Disposition: attachment; filename="architecture-v2.pdf"
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN (not READY yet, or
VIRUS_DETECTED), 404 NOT_FOUND, 410 GONE (file deleted).
3.2 GET /files/:fileId/thumbnail
302 redirect to the generated thumbnail (or 404 if none was generated,
e.g. for application/zip). Same auth as download.
3.3 DELETE /files/:fileId
Deletes the file record and schedules object-storage deletion (object + thumbnails + all versions). Blocks referencing the file keep their id but render a "file deleted" placeholder.
- Auth: JWT or API key (scope
WRITE).EDITon the file's anchor (or the uploader).
202 Accepted
{ "data": { "id": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "status": "deleting" } }
Errors: 403 FORBIDDEN, 404 NOT_FOUND.
Events emitted: file.deleted.
4. Versions
A file can be replaced in place (same fileId — blocks that reference
it keep working): each replacement creates a FileVersion row and keeps the
previous object in storage.
POST /files/:fileId/versions
Upload a new version: same two-step flow, but POST /files with
fileId: "<existing>" instead of a new file.
- Auth: JWT or API key (scope
WRITE).EDIT.
POST /files→{ "fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "name": "architecture-v3.pdf", "size": 512000, "mimeType": "application/pdf" }→201with a freshuploadUrl(the response includes"isVersion": true, "baseVersion": 1).PUTthe object.POST /files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4/complete→202.
When the new version reaches READY: the file's size/mimeType/
metadata/key update, version bumps to 2, and the old object is retained
as file_versions[{id, version: 1, key, size, createdBy, createdAt}].
Constraints: same mimeType as the base file (replacing a PDF with an
image → 422 UNPROCESSABLE_ENTITY, details: [{issue: "mime_change_not_allowed"}]);
max 10 versions per file (oldest is pruned when exceeded); the name may
change per version.
Events emitted: file.version_added (carries baseVersion,
newVersion).
GET /files/:fileId/versions
- Auth: JWT or API key (scope
READ).VIEW.
200 OK
{
"data": [
{ "id": "fvr_01J9ZKPC4E1F3A5B7C9D1E3F5A", "fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "version": 2, "key": "…/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4-v2", "size": 512000, "createdBy": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "createdAt": "2025-01-04T09:00:00.000Z" },
{ "id": "fvr_01J9ZKPC3D0E2F4A6B8C0D2E4A", "fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "version": 1, "key": "…/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "size": 482113, "createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A", "createdAt": "2025-01-02T10:20:58.000Z" }
]
}
POST /files/:fileId/versions/:version/restore
Swap the current object back to this version (creates another version entry copying the old object — history is append-only).
- Auth: JWT or API key (scope
WRITE).EDIT.
200 OK — the file object with the new current key/size/version.
Events emitted: file.version_restored.
5. Sizes & MIME Limits
| Class | Allowed MIME (examples) | Per-file max | Plan default |
|---|---|---|---|
| Images | image/png, image/jpeg, image/gif, image/webp, image/svg+xml, image/avif |
25 MB (SVG 2 MB) | 10 GB workspace |
| Documents | application/pdf, application/msword, application/vnd.openxmlformats-officedocument.*, text/plain, text/markdown, text/csv, application/json |
50 MB | same |
| Video | video/mp4, video/webm |
2 GB | 50 GB workspace (team plan) |
| Audio | audio/mpeg, audio/ogg, audio/wav, audio/mp4 |
200 MB | 20 GB workspace |
| Archives | application/zip, application/gzip, application/x-tar |
1 GB | 50 GB workspace |
| Other | anything else (always scanned, no inline preview) | 50 MB | counted to workspace storage |
image/svg+xmlis sanitized (script/event handlers stripped) before serving — it never executes.- Executables (
.exe,.apk,application/x-msdownload, …) are rejected at session creation with403 FORBIDDEN(mime_not_allowed) even when the MIME header is faked: extension and content sniffing are both checked atcomplete. - Workspace storage usage is enforced at session creation
(
413 PAYLOAD_TOO_LARGE,details: [{issue: "storage_quota", used, limit}]). - All files, regardless of class, pass the ClamAV scan;
VIRUS_DETECTEDfiles are kept (for inspection by admins viaGET /files?status=VIRUS_DETECTED) but are never downloadable.
6. curl Examples
# 1. Create upload session
curl -s -X POST https://factory.example.com/api/v1/files \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"architecture-v2.pdf","size":482113,"mimeType":"application/pdf","pageId":"pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9"}'
# → { "data": { "fileId": "fil_01J9ZKP3D8F0A2B4C6D8E0F2A4", "uploadUrl": "https://…" } }
# 2. PUT the bytes
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @architecture-v2.pdf
# 3. Complete → triggers pipeline
curl -s -X POST https://factory.example.com/api/v1/files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4/complete \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 4. Poll status until READY
curl -s https://factory.example.com/api/v1/files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4 \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 5. Download (follow redirect)
curl -sL https://factory.example.com/api/v1/files/fil_01J9ZKP3D8F0A2B4C6D8E0F2A4/download \
-H "Authorization: Bearer $ACCESS_TOKEN" -o architecture-v2.pdf
7. Events Summary
| Operation | Domain event(s) |
|---|---|
| Upload session created | file.upload_session_created (audit only — not a webhook event) |
| Pipeline reaches READY | file.uploaded (payload: full file object; drives search index + thumbnail consumers) |
| New version ready | file.version_added |
| Version restored | file.version_restored |
| File deleted | file.deleted |
file.uploaded is one of the subscribable webhook events.
8. TASK-0103 addendum — the implemented contract (authoritative)
Written against
apps/api/src/modules/files/(TASK-0101/0102/0103) and verified bytests/integration/files.integration.test.ts. Where this section and the draft (§1–7) disagree, this section wins.
8.0 File object (wire)
The file record never exposes the raw storage key and never a stable
url field. Content is reached only via signed URLs minted per read.
{
"id": "fil_…", "workspaceId": "wks_…", "blockId": "blk_…" , "pageId": "pg_…",
"name": "architecture-v2.pdf", "size": 482113, "mimeType": "application/pdf",
"status": "READY",
"metadata": {
"sha256": "3a7b…", "pdf": { "pages": 14 },
"dimensions": { "width": 1400, "height": 933, "orientation": "landscape" },
"media": { "duration": 12.4, "codec": "h264" },
"pipeline": { "error": null }
},
"thumbnailKey": "…/thumbs/…", "thumbnailUrl": "https://…signed GET, 15 min…",
"scanReport": { "engine": "clamav", "result": "clean" },
"uploadedBy": "usr_…", "version": 1,
"createdAt": "…", "updatedAt": "…"
}
status lifecycle: UPLOADING → SCANNING → READY | VIRUS_DETECTED | FAILED.
metadata keys are optional and kind-specific: media (video/audio:
duration, codec), pdf (pages), dimensions (image/video),
pipeline.error (set on FAILED).
8.1 POST /files/uploads — create an upload session
Body: { name, mimeType, size, pageId?, blockId?, replaceFileId? }
(replaceFileId is the TASK-0104 version path). The workspace resolves from
the x-factory-workspace header, else claims.workspaces[0].
- Plan size limit enforced here:
413 FILE_TOO_LARGE(effective limit = min(plan default, adminusageLimits.maxFileSize, 500 MB hard ceiling)). - Storage quota enforced here:
413 QUOTA_EXCEEDED. - MIME allow-list enforced here:
403(blocked types).
201 → { "upload": { "file": <file record, status UPLOADING>, "uploadUrl", "method": "PUT", "headers": { "Content-Type": <validated mime> }, "expiresAt" } }
(the session expires in 900 s). The client must send the signed headers exactly
on the PUT.
8.2 PUT /files/uploads/:id/complete
After the PUT to uploadUrl succeeds. Verifies the object (HEAD size vs
declared), sniffs magic bytes (mismatch → FAILED MIME_MISMATCH, object
deleted), then flips SCANNING + enqueues the worker pipeline.
- 200 →
{ id, status: "SCANNING", verifiedBy }. 410 UPLOAD_EXPIRED(session/object expired — no object landed);422 UPLOAD_INCOMPLETE(size mismatch / partial PUT).
8.3 POST /files/uploads/:id/cancel
Deletes the partial object + row. 200 → { deleted: true }.
8.4 GET /files — list (server-side permission-filtered, keyset-paginated)
Query params (validated, dto.ts ListFilesQuery):
| Param | Values | Default |
|---|---|---|
scope |
workspace | page |
workspace |
pageId |
required with scope=page |
— |
kind |
image | video | audio | pdf | doc | other |
all |
status |
UPLOADING | SCANNING | READY | VIRUS_DETECTED | FAILED |
all |
search |
substring on name (≤ 200) | — |
sort |
updated | name | size | date |
updated |
cursor |
opaque (base64url {id, sortValue}) |
first page |
limit |
1–100 | 50 |
200 → { "data": [ <file record> + { uploadedByName, pageName, versionCount } ], "nextCursor" }.
nextCursoris non-null only when the page was full (so a client loops until it is null).- Ties are broken by
id(same dir), so pagination is stable. - Permission model: a member sees files in the workspace; per-page
VIEW-gating (files in a private page are absent for non-viewers) is TASK-0104 — today the workspace-levelVIEWgate applies (non-member → 403, member → whole-workspace list).
8.5 GET /files/:id — detail + fresh signed URLs
200 → file record + { downloadUrl, previewUrl, expiresAt, versions[] }.
downloadUrl— signed GET,attachmentdisposition (900 s).previewUrl— signed GET,inlinedisposition (900 s) — the ONLY fetch path the UI uses for content (img/video/audio/pdf/iframe).expiresAt— when those URLs expire; the client re-fetches (the block re-mints) whennow + 60 s > expiresAt.versions[]—{ id, version, size, createdAt }(history).
8.6 GET /files/:id/download / /files/:id/preview — 302 redirects
Server-side, permission-checked, audit-logged (file.downloaded).
download → attachment; preview → inline. Short-lived signed Location.
403 when not READY or VIRUS_DETECTED; 404 unknown; 410 deleted.
8.7 PATCH /files/:id — rename
Body { name } (1–512). Renames the record only (storage key is
immutably built from fileId). 200 → file record.
8.8 Error codes (implemented)
| Code | Status | Where |
|---|---|---|
FILE_TOO_LARGE |
413 | session create (plan limit) |
QUOTA_EXCEEDED |
413 | session create (storage) |
UPLOAD_EXPIRED |
410 | complete (expired / object missing) |
UPLOAD_INCOMPLETE |
422 | complete (size mismatch) |
MIME_MISMATCH |
415 | complete (magic-byte sniff vs declared) |
FORBIDDEN |
403 | no VIEW/EDIT; blocked MIME at session |
8.9 Upload-session curl (implemented)
# 1. session
curl -s -X POST $API/files/uploads -H "Authorization: Bearer $T" \
-H "x-factory-workspace: $WS" -H 'Content-Type: application/json' \
-d '{"name":"a.pdf","size":482113,"mimeType":"application/pdf"}'
# → { "upload": { "file": {...}, "uploadUrl": "...", "method":"PUT", "headers":{"Content-Type":"application/pdf"}, "expiresAt":"..." } }
# 2. PUT the bytes (send the signed headers EXACTLY)
curl -s -X PUT "$UPLOAD_URL" -H 'Content-Type: application/pdf' --data-binary @a.pdf
# 3. complete → SCANNING + worker pipeline
curl -s -X PUT $API/files/uploads/$FILE_ID/complete -H "Authorization: Bearer $T"
# 4. poll the detail (fresh signed URLs appear at READY)
curl -s $API/files/$FILE_ID -H "Authorization: Bearer $T"
# 5. download (302, audited) / list
curl -sL $API/files/$FILE_ID/download -H "Authorization: Bearer $T" -o a.pdf
curl -s "$API/files?scope=workspace&sort=updated&limit=50" -H "Authorization: Bearer $T"
Web UI client: apps/web/src/lib/files/api.ts (the filesApi) is the
single source of truth for these endpoints; the legacy fileApi in
lib/api.ts was removed (its endpoints 404'd) and uploadFile is now a
thin adapter over lib/files/upload.ts. UI behavior is documented in
../ui/files.md.
9. TASK-0104 addendum — versions, trash, permissions, quota (authoritative)
Written against
apps/api/src/modules/files/+apps/api/src/lib/authorize.ts(TASK-0104) and verified bytests/integration/files-lifecycle.integration. test.ts. Where this section and the draft (§1–7, incl. its §3.3/§4) disagree, this section wins.Supersedes the draft's versions/trash story. The draft §4 described
POST /files/:fileId/versions+POST …/versions/:version/restoreand §3.3 aDELETE → 202 {status:"deleting"}— none of those endpoints existed. TASK-0104 implements the spec's real contract: a version is a re-upload withreplaceFileId, rollback is a key swap, delete is a soft trash with a separate?purge=truehard-delete.
9.0 Route map (all under the /files controller; files/trash is
declared BEFORE files/:id so it is not swallowed by :id)
| Method | Path | Purpose | Min level |
|---|---|---|---|
| GET | files |
list (see §8.4) | VIEW |
| GET | files/trash |
workspace trash | VIEW |
| GET | files/:id |
detail + signed URLs (§8.5) | VIEW |
| GET | files/:id/download / preview |
302 signed (§8.6) | VIEW |
| GET | files/:id/versions |
version list (§9.1) | VIEW |
| POST | files/:id/rollback |
make version n current (§9.2) | EDIT |
| POST | files/:id/versions/prune |
delete old versions (§9.3) | EDIT |
| DELETE | files/:id |
trash (soft) (§9.4) | EDIT |
| DELETE | files/:id?purge=true |
purge (hard) (§9.5) | FULL_ACCESS or ws Admin/Owner |
| POST | files/:id/restore |
restore from trash (§9.6) | EDIT |
| GET | files/:id/permissions |
list explicit grants (§9.7) | EDIT |
| PUT | files/:id/permissions |
replace explicit grants (§9.7) | EDIT |
| PATCH | files/:id |
rename (§8.7) | EDIT |
9.1 Versions
A version is created by re-uploading with replaceFileId.
POST /files/uploads body { name, mimeType, size, replaceFileId: "<fileId>" }
→ 201, same session shape as §8.1, but the session is anchored to the
EXISTING file (its page/block are inherited; the request's anchors are
ignored). On complete, the worker finalizes a FileVersion n+1 and the file
re-enters SCANNING; when it reaches READY, the file's key/size/
mimeType/version re-point to the new object. The previous object is
RETAINED (its FileVersion row stays until §9.3 or §9.5).
GET /files/:id/versions → bare array, ascending:
[
{ "id": "fvr_…", "version": 1, "size": 482113, "current": false,
"createdBy": "usr_…", "createdByName": "Ada", "createdAt": "…" },
{ "id": "fvr_…", "version": 2, "size": 512000, "current": true,
"createdBy": "usr_…", "createdByName": "Ada", "createdAt": "…" }
]
The raw storage key is never returned (spec §Security — signed URLs
only). current is the version whose key equals the file's current key.
POST /files/:id/rollback body { version: <int> } → the file record.
Key swap, not a copy: File.key/size are re-pointed at the target
version's existing object (no new object, no re-scan). Audit file.rolled_back.
POST /files/:id/versions/prune → { "pruned": <int> }. Deletes every
NON-CURRENT version's object + FileVersion row (keeps the current one),
and decrements Workspace.usageStorageBytes by the pruned bytes. Audit
file.versions_pruned { pruned }.
9.2 Trash → restore → purge
DELETE /files/:id → { "trashed": true, "deletedAt": "…" }. Soft
delete: deletedAt is set; the object + version rows are RETAINED. The file
leaves GET /files and the search index (the worker removes the doc on the
file.deleted event, target < 30 s). Audit file.deleted; event file.deleted.
Re-trashing a trashed file → 409 CONFLICT (already_trashed).
GET /files/trash → bare array of the workspace's trashed files
(file record + { deletedAt, uploadedByName }, most-recent first, ≤ 200).
POST /files/:id/restore → the file record. Clears deletedAt; the file
returns to GET /files + the search index (re-emits file.uploaded with
restored: true). Audit file.restored. Storage counter is unchanged
(the bytes were retained while trashed — no delta).
DELETE /files/:id?purge=true → { "purged": true }. Hard delete: every
version object + the thumbnail + the File row + all FileVersion rows are
removed (one transaction) and usageStorageBytes is decremented by the file's
total bytes. Requires FULL_ACCESS on the file (explicit grant) OR a
workspace Admin/Owner role; otherwise 403 + audit file.purge_denied.
Audit file.purged { versions }. A purged file is gone from the bucket
(pnpm storage:orphans --file <id> reports 0 for it).
9.3 File-level permissions (explicit grants + explicit deny)
Permissions resolve to an effective level per (actor, file): the most
specific of the actor's explicit file grants; an explicit NONE grant is
a hard deny that overrides any inherited level (file → block → page →
team → workspace). No grant → inherit from the page/block/workspace (a
non-member still sees nothing unless granted).
GET /files/:id/permissions → bare array of the file's explicit grants:
[ { "id": "perm_…", "granteeType": "user", "granteeId": "usr_…",
"level": "NONE", "inheritsFrom": null,
"granteeName": "Eve", "granteeEmail": "eve@…" } ]
granteeType ∈ user | team | role (role ⇒ granteeId is a role name:
OWNER/ADMIN/MEMBER/GUEST).
PUT /files/:id/permissions body { "grants": [ { "granteeType", "granteeId", "level" } ] } → the updated bare array. This is a
REPLACE (send the full desired set; omitted grants are deleted).
level may be NONE (explicit deny). Audit file.permission_changed;
event permission.changed (drives the search re-index so the doc is now
visible/hidden to the grantee).
Access to read/download/preview a file requires effective level ≥ VIEW;
a denial → 403 + audit file.download_denied { actor, ip, path }. A
VIRUS_DETECTED file → 403 FILE_QUARANTINED on download/preview (the detail
endpoint still returns the record with no signed URLs, §8.5).
9.4 Storage quota
Workspace.usageStorageBytes (BigInt, the event counter) is maintained
transactionally with the byte-moving operation:
| Operation | Counter delta |
|---|---|
complete (upload / new version) |
+ size (bytes land in the bucket at complete) |
versions/prune |
− Σ pruned bytes |
purge |
− Σ all version bytes |
restore |
± 0 (bytes were retained) |
The upload-session pre-check reads the counter O(1) (advisory-fast); only at
the denial boundary is the authoritative ledger sum re-checked, and only a
stale-LOW counter (drift > 1 GB) is corrected before a 413 QUOTA_EXCEEDED
(naming used/limit in the message + details: { maxBytes, usedBytes, remainingBytes }). The workspace usage endpoint
(GET /workspaces/:id/usage) returns the counter as
usage.storageBytes (a decimal string) and the effective plan quota as
limits.maxStorageBytes (min(plan quota, usageLimits override)).
9.5 Audit events (TASK-0104)
| Action | When | Key metadata |
|---|---|---|
file.version_created |
a replaceFileId upload completes |
version, name, size |
file.rolled_back |
POST /:id/rollback |
version |
file.versions_pruned |
POST /:id/versions/prune |
pruned |
file.deleted |
DELETE /:id (soft) |
name, size, key |
file.restored |
POST /:id/restore |
name, size, key |
file.purge_denied |
purge without FULL_ACCESS/Admin | name, key |
file.purged |
DELETE /:id?purge=true |
key, thumbPrefix, versions |
file.permission_changed |
PUT /:id/permissions |
name, grants |
file.download_denied |
download/preview permission/quarantine fail | actor, ip, path |
file.orphan_purged |
worker orphan sweep deletes a stray object | key, reason |
9.6 Events (TASK-0104)
| Operation | Domain event |
|---|---|
| New version completes | file.uploaded (carries version) — drives the search re-index |
| File trashed | file.deleted (worker removes the search doc) |
| File restored | file.uploaded (restored: true) — worker re-indexes |
| File renamed | file.updated (carries name) — worker re-upserts the doc (title converges) |
| File permissions changed | permission.changed — worker enqueues a workspace resync (runReindex) |
file.updated is a convergence trigger (re-index on rename), NOT a
webhook-catalog event — it is not a user-facing notification.
file.deleted / file.uploaded / file.updated / permission.changed are
stream-internal (search-index consumers); the purge path deletes rows directly
(the 15-min contentHash search reconcile backstops any doc whose row was
removed). Note (rule-29 conflict): search visibility for a file doc is
WORKSPACE-scoped (the workspace floor) — file-level Permission grants are
enforced at read time, not search time, so permission.changed does not
change whether a doc is returned to a grantee; it re-runs the resync so any
converged metadata (e.g. workspace/teamspace/page-scoped changes) is current.
9.7 Orphan sweep
Objects under files/ with no matching File/FileVersion row are orphans
(crashed finalizes, partial transactions). The daily maintenance:orphans
worker job (7-day grace on the object's Last-Modified) and the manual
pnpm storage:orphans CLI (dry-run by default; --purge deletes;
--file <id> scopes) share apps/worker/src/processors/files/cleanup.ts.
Each deletion is audited file.orphan_purged.