Skip to main content

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

HeaderDirectionNotes
Authorization: Bearer <jwt>inboundrequired on all non-health routes
X-Tenant-Id: tnt_<ULID>inboundmust equal JWT tenant_id claim
X-Request-Id: <ULID>inboundclient-supplied, echoed back
Idempotency-Key: <ULID>inboundrequired on POST /uploads, /uploads/{ups}/confirm, /files/{med}/download-url, /files/erasure
If-Match: <version>inboundrequired on PATCH /files/{med}
Accept-Language: <bcp-47>inbounddrives userMessageKey resolution
traceparent (W3C)inboundpropagated to logs & events
ETag: <version>outboundreflects 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):

ScopeAllowed Content-Type
property_photoimage/jpeg, image/png, image/webp, image/heic
tenant_logo / theme_assetimage/svg+xml, image/png, image/webp
invoice_pdf / receipt_scanapplication/pdf
guest_id_scanimage/jpeg, image/png, application/pdf
vendor_lock_reportapplication/pdf, text/csv, application/json
notification_attachmentapplication/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:

StatusCodeWhen
422MELMASTOON.FILE.CONTENT_TYPE_NOT_ALLOWEDscope ↔ MIME mismatch
422MELMASTOON.FILE.OBJECT_TOO_LARGEdeclared bytes exceed scope cap
402MELMASTOON.FILE.QUOTA_EXCEEDEDtenant cap reached
422MELMASTOON.FILE.RETENTION_POLICY_UNKNOWNretentionPolicyName not in registry
429MELMASTOON.GENERAL.RATE_LIMITEDper-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}/variants200

{
"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}/restore200 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:

StatusCode
404MELMASTOON.GENERAL.RESOURCE_NOT_FOUND (file unknown OR wrong tenant)
409MELMASTOON.FILE.SCAN_PENDING (status ∈ uploaded/scanning/initiated)
409MELMASTOON.FILE.QUARANTINED
410MELMASTOON.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=50200

{
"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/quotas200

{
"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

RoutePurpose
GET /healthzLiveness — process responsive
GET /readyzReadiness — DB + Pub/Sub + Redis + GCS reachable
GET /metricsPrometheus 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 surfaceWindowCap
Backoffice (per user)1 min600 req
Tenant booking BFF1 min1200 req
Consumer BFF1 min1800 req
Internal callbacks1 min12000 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/v2 parallel path for ≥ 90 days; deprecation header Sunset: on /api/v1.
  • OpenAPI diff CI gate enforces the rule.