file-storage-service — API Contracts
Companion: APPLICATION_LOGIC · DOMAIN_MODEL · 05 API Design · Error Codes
All routes follow docs/05-api-design.md: JSON over HTTPS, ULID IDs (<prefix>_<ULID>), X-Tenant-Id header (verified against JWT claim), Idempotency-Key on writes, RFC 9457 errors. Base URL https://api.melmastoon.ghasi.io. Internal callback routes use the prefix /internal/v1 and require an mTLS-authenticated platform service account.
OpenAPI source of truth: services/file-storage-service/contracts/openapi.yaml (generated from controllers + DTOs).
1. Common headers
| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer <jwt> | inbound | required on all non-health routes |
X-Tenant-Id: tnt_<ULID> | inbound | must equal JWT tenant_id claim |
X-Request-Id: <ULID> | inbound | client-supplied, echoed back |
Idempotency-Key: <ULID> | inbound | required on POST /uploads, /uploads/{ups}/confirm, /files/{med}/download-url, /files/erasure |
If-Match: <version> | inbound | required on PATCH /files/{med} |
Accept-Language: <bcp-47> | inbound | drives userMessageKey resolution |
traceparent (W3C) | inbound | propagated to logs & events |
ETag: <version> | outbound | reflects aggregate version |
2. Uploads
2.1 Initiate upload
POST /api/v1/files/uploads
{
"scope": "property_photo",
"contentType": "image/jpeg",
"bytes": 384210,
"ownerScopeRefs": { "propertyId": "ppt_01H...", "photoSlot": "gallery" },
"filenameHint": "lobby-sunset.jpg",
"preferredLocale": "en",
"retentionPolicyName": "default",
"resumable": false
}
Allowed scope values: property_photo, tenant_logo, theme_asset, invoice_pdf, receipt_scan, guest_id_scan, vendor_lock_report, notification_attachment, misc.
Allowed MIME × scope (canonical, partial — full matrix in SECURITY_MODEL §5):
| Scope | Allowed Content-Type |
|---|---|
property_photo | image/jpeg, image/png, image/webp, image/heic |
tenant_logo / theme_asset | image/svg+xml, image/png, image/webp |
invoice_pdf / receipt_scan | application/pdf |
guest_id_scan | image/jpeg, image/png, application/pdf |
vendor_lock_report | application/pdf, text/csv, application/json |
notification_attachment | application/pdf, image/jpeg, image/png |
201 Created:
{
"fileObjectId": "med_01HXY...",
"uploadSessionId": "ups_01HXY...",
"uploadMethod": "PUT",
"uploadUrl": "https://storage.googleapis.com/melmastoon-media-prod/tenants/tnt_01H.../property_photo/2026/04/22/med_01HXY....jpg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Signature=...",
"uploadHeaders": {
"Content-Type": "image/jpeg",
"x-goog-content-length-range": "0,52428800"
},
"resumableSessionUri": null,
"expiresAt": "2026-04-22T08:24:11Z",
"objectKey": "tenants/tnt_01H.../property_photo/2026/04/22/med_01HXY....jpg"
}
For resumable: true (or bytes > 8 MiB), the response uses uploadMethod: "RESUMABLE" and includes resumableSessionUri (the GCS resumable session URL); the caller PUTs chunks against that URI per GCS resumable upload protocol.
Errors:
| Status | Code | When |
|---|---|---|
| 422 | MELMASTOON.FILE.CONTENT_TYPE_NOT_ALLOWED | scope ↔ MIME mismatch |
| 422 | MELMASTOON.FILE.OBJECT_TOO_LARGE | declared bytes exceed scope cap |
| 402 | MELMASTOON.FILE.QUOTA_EXCEEDED | tenant cap reached |
| 422 | MELMASTOON.FILE.RETENTION_POLICY_UNKNOWN | retentionPolicyName not in registry |
| 429 | MELMASTOON.GENERAL.RATE_LIMITED | per-tenant rate exceeded |
2.2 Confirm upload
POST /api/v1/files/uploads/{ups}/confirm
{ "sha256": "f3c1b2..." }
200:
{
"fileObjectId": "med_01HXY...",
"alias": false,
"status": "uploaded",
"scanRequested": true
}
If a duplicate is detected:
{
"fileObjectId": "med_01HCANONICAL...",
"alias": true,
"status": "ready",
"scanRequested": false
}
2.3 Abort upload
POST /api/v1/files/uploads/{ups}/abort
{ "reason": "user_cancelled" }
200 empty body. Subsequent confirm returns 409 MELMASTOON.FILE.UPLOAD_SESSION_CLOSED.
3. Files
3.1 Get metadata
GET /api/v1/files/{med} → 200 FileObjectDto
{
"id": "med_01HXY...",
"tenantId": "tnt_01H...",
"status": "ready",
"scope": "property_photo",
"dataClass": "public_media",
"contentType": "image/jpeg",
"bytes": 384210,
"sha256": "f3c1b2...",
"objectKey": "tenants/tnt_01H.../property_photo/2026/04/22/med_01HXY....jpg",
"ownerScopeRefs": { "propertyId": "ppt_01H...", "photoSlot": "gallery" },
"retentionPolicyName": "default",
"altText": { "default": "en", "values": { "en": "Lobby at sunset" } },
"tags": ["lobby"],
"variants": [
{ "preset": "thumb", "status": "ready", "widthPx": 320, "heightPx": 213, "bytes": 18432, "contentType": "image/webp" },
{ "preset": "hero", "status": "ready", "widthPx": 1280, "heightPx": 853, "bytes": 124210, "contentType": "image/webp" },
{ "preset": "full", "status": "ready", "widthPx": 1920, "heightPx": 1280,"bytes": 284210, "contentType": "image/webp" },
{ "preset": "avif_hero", "status": "ready", "widthPx": 1280, "heightPx": 853, "bytes": 92410, "contentType": "image/avif" }
],
"scanResult": { "verdict": "passed", "scanner": "clamav", "scannedAt": "2026-04-22T08:24:43Z" },
"aiProvenance": null,
"createdAt": "2026-04-22T08:14:11Z",
"updatedAt": "2026-04-22T08:25:01Z",
"version": 5
}
3.2 List variants
GET /api/v1/files/{med}/variants → 200
{
"items": [
{ "preset": "thumb", "status": "ready", "widthPx": 320, "heightPx": 213, "bytes": 18432, "contentType": "image/webp" }
]
}
3.3 Patch metadata
PATCH /api/v1/files/{med} (requires If-Match)
{ "altText": { "default": "en", "values": { "en": "Lobby at sunset, renovated" } }, "tags": ["lobby","renovated"] }
Only altText and tags are mutable post-ready.
3.4 Soft delete
DELETE /api/v1/files/{med} → 204. Emits file.deleted.v1. The bytes remain for the 30-day archive window unless an erasure shortens it.
3.5 Restore
POST /api/v1/files/{med}/restore → 200 FileObjectDto. Allowed only while status='archived' and within 30 days.
3.6 Override quarantine (security-reviewer only)
POST /api/v1/files/{med}/override-quarantine
{ "decision": "release_to_archive", "ticketId": "INC-12345", "reviewer": "usr_01H..." }
Roles: Platform.Security, Platform.Admin. Audit row + reversal-audit event emitted. Never moves the file back to ready.
4. Downloads
4.1 Issue signed download URL
POST /api/v1/files/{med}/download-url
{
"variant": "hero",
"ttlSeconds": 300,
"purpose": "view",
"responseDisposition": "inline"
}
200:
{
"url": "https://storage.googleapis.com/melmastoon-media-prod/tenants/tnt_01H.../property_photo/2026/04/22/med_01HXY..._hero.webp?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Date=...&X-Goog-Expires=300&X-Goog-Signature=...",
"method": "GET",
"headers": { "Cache-Control": "private, max-age=300" },
"expiresAt": "2026-04-22T08:30:00Z",
"accessGrantId": "grt_01H..."
}
For CDN-fronted public assets the response can also include cdnUrl with a long-TTL signed cookie/URL pair (only for dataClass=public_media).
Errors:
| Status | Code |
|---|---|
| 404 | MELMASTOON.GENERAL.RESOURCE_NOT_FOUND (file unknown OR wrong tenant) |
| 409 | MELMASTOON.FILE.SCAN_PENDING (status ∈ uploaded/scanning/initiated) |
| 409 | MELMASTOON.FILE.QUARANTINED |
| 410 | MELMASTOON.FILE.ARCHIVED (status archived) — except for restore/audit purposes |
4.2 Revoke a previously issued URL
DELETE /api/v1/files/{med}/access-grants/{grt} → 204. Adds the URL signature fingerprint to the Redis blacklist for the remainder of its TTL.
4.3 Access log
GET /api/v1/files/{med}/access-log?from=2026-04-01&to=2026-04-22&page=1&pageSize=50 → 200
{
"items": [
{ "id": "grt_01H...", "actor": { "userId": "usr_01H...", "kind": "user" }, "purpose": "view", "issuedAt": "2026-04-22T08:25:01Z", "expiresAt": "2026-04-22T08:30:01Z", "callerIp": "203.0.113.7", "callerUserAgent": "Mozilla/5.0 ...", "revokedAt": null }
],
"page": { "current": 1, "size": 50, "total": 137 }
}
5. Quotas
5.1 Get quota
GET /api/v1/files/quotas → 200
{
"tenantId": "tnt_01H...",
"bytesUsed": "21474836480",
"bytesCap": "53687091200",
"objectsUsed": 18432,
"objectsCap": 100000,
"byScope": {
"property_photo": { "bytes": "18253611008", "objects": 14210 },
"guest_id_scan": { "bytes": "1073741824", "objects": 3120 },
"invoice_pdf": { "bytes": "1610612736", "objects": 980 },
"tenant_logo": { "bytes": "131072", "objects": 2 },
"theme_asset": { "bytes": "536870912", "objects": 120 }
},
"warnings": ["bytes_above_80pct"]
}
5.2 Update quota override (platform admin)
PUT /internal/v1/files/quotas/{tenantId}
{ "bytesCap": "107374182400", "objectsCap": 200000 }
Roles: Platform.Admin only. Audited.
6. Erasure (GDPR)
6.1 Initiate erasure by guest
POST /api/v1/files/erasure
{
"scope": { "kind": "guest", "guestId": "gst_01H..." },
"requestedBy": "usr_01H...",
"reason": "guest_request_gdpr",
"ticketId": "GDPR-2026-04-22-007"
}
202 Accepted:
{
"erasureRequestId": "ers_01H...",
"status": "in_progress",
"matchedObjects": 14,
"deferredObjects": 2,
"deferredReason": "regulated_min_retention",
"certificateUrl": null
}
Polling: GET /api/v1/files/erasure/{ers} returns the certificate when complete.
6.2 Initiate erasure by tenant
Same shape with scope: { "kind": "tenant" }. Restricted to Platform.Admin and runs only after the tenant's holdUntil legal window has elapsed.
6.3 Erasure certificate
GET /api/v1/files/erasure/{ers} → 200
{
"id": "ers_01H...",
"scope": { "kind": "guest", "guestId": "gst_01H..." },
"requestedAt": "2026-04-22T09:00:00Z",
"completedAt": "2026-04-22T09:02:11Z",
"matchedObjects": 14,
"purgedObjects": 12,
"deferredObjects": 2,
"deferred": [ { "fileObjectId": "med_01H...", "policy": "tax_compliance", "releasedAt": "2033-04-22T00:00:00Z" } ],
"purgedBytes": "184320571",
"cdnInvalidated": true,
"certificateSha256": "9a82c1...",
"signedBy": "platform-erasure-signer@melmastoon-prod.iam.gserviceaccount.com"
}
7. Internal callbacks (mTLS only)
7.1 Scan callback
POST /internal/v1/files/scan-callback
{
"fileObjectId": "med_01H...",
"scanResultId": "scn_01H...",
"verdict": "passed",
"scanner": "clamav",
"engineVersion": "1.3.1",
"definitionsVersion": "27192",
"scannedAt": "2026-04-22T08:24:43Z",
"threats": []
}
200 → triggers recordScanPassed or recordScanFailed and downstream events.
7.2 Optimize callback
POST /internal/v1/files/optimize-callback
{
"fileObjectId": "med_01H...",
"preset": "hero",
"status": "ready",
"objectKey": "tenants/tnt_01H.../property_photo/2026/04/22/med_01HXY..._hero.webp",
"contentType": "image/webp",
"bytes": 124210,
"widthPx": 1280,
"heightPx": 853,
"isLastOfBatch": false
}
When isLastOfBatch=true the service emits file.optimization.completed.v1.
7.3 CDN invalidation request (internal)
POST /internal/v1/files/cdn/invalidate
{ "paths": ["/tenants/tnt_01H.../property_photo/2026/04/22/*"] }
8. Health
| Route | Purpose |
|---|---|
GET /healthz | Liveness — process responsive |
GET /readyz | Readiness — DB + Pub/Sub + Redis + GCS reachable |
GET /metrics | Prometheus exposition (only on internal port 9090) |
9. OpenAPI excerpt — initiate upload
paths:
/api/v1/files/uploads:
post:
operationId: initiateUpload
summary: Initiate a signed upload session
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/InitiateUploadRequest' }
responses:
'201':
description: Session created
content:
application/json:
schema: { $ref: '#/components/schemas/InitiateUploadResponse' }
'402':
description: Quota exceeded
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
quota:
value: { error: { code: 'MELMASTOON.FILE.QUOTA_EXCEEDED', status: 402, title: 'Tenant storage quota exceeded', detail: 'bytes_used + declared > bytes_cap', retriable: false } }
'422':
description: Validation failed
10. Rate limits
| Caller surface | Window | Cap |
|---|---|---|
| Backoffice (per user) | 1 min | 600 req |
| Tenant booking BFF | 1 min | 1200 req |
| Consumer BFF | 1 min | 1800 req |
| Internal callbacks | 1 min | 12000 req |
POST /uploads is additionally capped at 120/min/tenant; POST /files/{med}/download-url at 600/min/tenant. Bursting up to 2× cap for 10 s. Exceed → 429 MELMASTOON.GENERAL.RATE_LIMITED with Retry-After.
11. Versioning posture
- Additive fields → backward-compatible within
/api/v1. - Breaking change →
/api/v2parallel path for ≥ 90 days; deprecation headerSunset:on/api/v1. - OpenAPI diff CI gate enforces the rule.