Status: Accepted Datum: 2026-05-24 Oblast: Infrastruktura / Storage / User-app SDK Navazuje na: ADR-023 (schema-per-app, isolation-by-infra filozofie), ADR-024 (per-namespace worker, pattern parity)
Context
Problém: user-appky potřebují file storage bez starostí s infrastrukturou
User-appky generované Marou (AI agent) potřebují schopnost ukládat a načítat soubory — avatary, přílohy, uživatelské uploady, generovaný obsah. Cíl je poskytnout SDK s “managed storage” feelingem: user-app kód volá jednoduchou knihovnu, neřeší credentials, billing ani infrastrukturu.
Před přijetím tohoto ADR existovaly čtyři otevřené architecture otázky:
- Storage provider — DO Spaces vs. Cloudflare R2 vs. AWS S3?
- Bucket strategy — sdílený bucket s code-managed prefix isolation, nebo per-tenant bucket?
- Gateway topology — centrální BE proxy nebo per-namespace storage mikroservis?
- Credentials — kde žijí, jak jsou scoped?
Proč ne DO Spaces
DO Spaces je přirozenou první volbou v rámci DigitalOcean stacku, ale narážíme na dvě strukturální omezení:
- Prefix-level IAM scope neexistuje. DO Spaces IAM neumí omezit klíč na prefix (
/tenant-42/*). Per-bucket access keys existují, ale: - Hard cap 100 buckets/account. Za alpha fáze to vypadá pohodlně, ale pro N tenantů × M prostředí (dev/prod) je to strukturální bloker — nedá se obejít support tiketem, je to product limit.
Kombinace těchto dvou limitů dělá z DO Spaces strategicky nevhodnou volbu pro per-tenant bucket architekuturu.
Proč ne AWS S3
AWS S3 má plnohodnotný IAM, per-bucket scoped policies a STS. Ale:
- Egress pricing: $0.09/GB — pro media-heavy user-appky je to nákladový strop.
- Třetí cloud provider v stacku (vedle DO a Cloudflare) bez znatelné přidané hodnoty nad R2.
- Žádné specifické výhody pro náš use case, které by kompenzovaly náklady a provozní overhead.
Proč Cloudflare R2
| Parametr | DO Spaces | AWS S3 | Cloudflare R2 |
|---|---|---|---|
| Egress | ~$0.02/GB | $0.09/GB | $0.00/GB |
| Storage | $0.02/GB | $0.023/GB | $0.015/GB |
| Bucket limit | 100 (hard) | neomezeno | 1000 (soft) |
| Per-bucket scoped token | ano (key pair) | IAM policy | R2 API token scoped na bucket |
| STS / federated credentials | ne | ano | ne (nevyžadujeme) |
| S3 API kompatibilita | částečná | nativní | plná |
| Prefix-level IAM | ne | ano | ne (řeší per-bucket token) |
R2 nabízí free egress, per-bucket scoped API tokeny a 1000-bucket soft cap (navyšitelný supportem). To jsou přesně parametry, které naše architektura vyžaduje.
Bucket strategy: sdílený prefix vs. per-tenant bucket
Sdílený bucket s code-managed prefix isolation je jednodušší operačně (1 bucket, 1 credentials),
ale přenáší izolaci do aplikačního kódu. Selhání v BE (bug, race condition) → přímé cross-tenant
data exposure. TalkIDE zažilo přesně tento typ incidentu: incident #148 (platform CLAUDE.md) —
/data/claude/projects/ sdílený PVC bez path isolation, plain-text jsonl čitelný napříč tenanty.
TalkIDE filozofie zavedená v ADR-023 (schema-per-app) a ADR-024 (worker per namespace) je “isolation by infra, ne by code”. Per-tenant bucket s per-bucket scoped tokenem je hardwarová izolace — kompromitace jednoho tokenu odhalí data právě jednoho tenanta.
Gateway topology: centrální BE proxy vs. per-namespace mikroservis
Centrální BE proxy drží na jednom místě tokeny všech tenantů. Kompromitace BE podu = kompromitace všech tokenů. Navíc jde proti ADR-024 vzoru, kde výpočetní workload patří do tenant namespace.
Per-namespace storage gateway je konzistentní s ADR-024: jeden lightweight pod per tenant-environment namespace, každý drží právě jeden R2 token (scoped na právě jeden bucket). Blast-radius kompromitace podu = data jednoho tenanta. Pattern parity s workerem není jen estetická, je to architektonický princip snižující kognitivní overhead.
Decision
1. Provider: Cloudflare R2
Zvolený storage provider je Cloudflare R2. DO Spaces odmítnut kvůli 100-bucket hard capu a absenci prefix-level IAM. AWS S3 odmítnut kvůli egress pricing a nadbytečné složitosti třetího cloud providera.
2. Bucket strategy: per-tenant bucket
Jeden R2 bucket per project-environment kombinaci. Naming pattern:
tk-{tenantId}-{projectSlug}-{env}
Příklady:
- tenant 42, projekt
todo-list, envdev→tk-42-todo-list-dev - tenant 1, projekt
my-saas, envprod→tk-1-my-saas-prod
Naming musí splňovat RFC 1123 DNS label pravidla (Cloudflare R2 bucket names jsou case-sensitive,
3–63 znaků, malá písmena, číslice a pomlčky). Pokud projectSlug obsahuje velká písmena nebo
podtržítka, K8sStorageProvisioner je normalizuje (lowercase, _ → -) před vytvořením bucketu.
3. Topology: per-namespace storage gateway
Nová Spring Boot mikroservis talkide-storage-svc deployovaná jako dedikovaný K8s Deployment
v každém tenant-environment namespace — vedle worker podu (ADR-024). Storage-svc se stará o:
- presigned URL generování pro upload a download
- listing objektů v bucketu
- mazání objektů
- autentizaci user-app volání (shared HMAC secret, viz §6)
Storage-svc nikdy nepředá R2 API token user-appce ani externímu klientovi. Veškeré interakce user-app s R2 jdou buď přes presigned URL (data plane, přímý browser→R2), nebo přes storage-svc API (control operace).
4. Credentials: per-bucket scoped R2 API token v K8s Secret
Pro každý tenant bucket Cloudflare R2 API umožňuje vytvořit token scoped na konkrétní bucket s granulárními oprávněními (čtení, zápis, mazání). Provisioner vytvoří token při provisioning projektu a uloží ho do K8s Secret v tenant-environment namespace.
Secret name: storage-svc-r2-{slug}-{env} (konzistentní s db-creds naming patternem).
Storage-svc pod mountuje Secret jako env vars:
R2_ACCESS_KEY_ID — S3-compatible key id
R2_SECRET_ACCESS_KEY — S3-compatible secret key
R2_BUCKET_NAME — jméno bucketu
R2_ACCOUNT_ID — Cloudflare account id (nutný pro endpoint URL)
Endpoint pro R2 S3 API: https://{ACCOUNT_ID}.r2.cloudflarestorage.com
Architecture
Přehledový diagram
graph TD
subgraph talkide["Namespace: talkide (control-plane)"]
BE["platform BE<br/>(Spring Boot)"]
PROV["K8sStorageProvisioner"]
BE --> PROV
end
subgraph tenant_ns["Namespace: tenant-env (např. mirek-dev)"]
WORKER["talkide-worker<br/>(Node/TS)"]
STORAGE_SVC["talkide-storage-svc<br/>(Spring Boot)"]
USER_APP["user-app pod<br/>(Mara-generated)"]
WORKER --> STORAGE_SVC
USER_APP --> STORAGE_SVC
end
subgraph cloudflare["Cloudflare"]
R2["R2 Bucket<br/>tk-{id}-{slug}-{env}"]
CF_API["Cloudflare API<br/>(bucket + token management)"]
end
PROV -->|"1. create bucket"| CF_API
PROV -->|"2. create scoped token"| CF_API
PROV -->|"3. K8s Secret + Deployment"| tenant_ns
STORAGE_SVC -->|"presign + ops"| R2
USER_APP_BROWSER["browser"] -->|"presigned URL (data plane)"| R2
Komponenty
K8sStorageProvisioner (platform talkide-be)
Nová třída v platform BE, volaná při Create Project (po namespace provisioning, souběžně nebo
po DB provisioning). Odpovědnosti:
- Volá Cloudflare API pro vytvoření R2 bucketu (
PUT /client/v4/accounts/{id}/r2/buckets/{name}). - Volá Cloudflare API pro vytvoření per-bucket scoped R2 API tokenu
(
POST /client/v4/accounts/{id}/r2/buckets/{name}/access-keysnebo přes tokens API s bucket policy). - Zapíše K8s Secret
storage-svc-r2-{slug}-{env}do tenant-environment namespace. - Aplikuje Helm template (nebo K8s manifest přes fabric8) pro
talkide-storage-svcDeployment v tenant namespace — analogicky jakoK8sWorkerProvisioner.
Teardown (Delete Project): smaže R2 bucket (včetně obsahu), zruší API token, smaže K8s Secret,
smaže storage-svc Deployment.
talkide-storage-svc
Nová Spring Boot mikroservis žije v samostatném git repo talkide/talkide-storage-svc
v GitLab group talkide — vedle ostatních platform repos (talkide-be, talkide-fe,
talkide-worker, talkide-infra, documentation, talkide-blog, talkide-docs).
- Stack: Kotlin + Spring Boot 3.x (stejný jako
talkide-beatalkide-workerthin seam). - Build: vlastní Gradle (Kotlin DSL) setup, vlastní
Dockerfile, imageregistry.digitalocean.com/talkide/talkide-storage-svc:{tag}v DO Container Registry (Professional plan = unlimited repos, viz CLAUDE.md infra reference). - CI: vlastní
.gitlab-ci.ymlswhen: manual(cost gate jako ostatní repos — viz CLAUDE.md sekce “CI/CD — manual cost gate”). - Helm templates: deploy manifesty (Deployment / Service / NetworkPolicy) NEžijí v tomto
repu, ale v
talkide-infra/charts/storage-svc/— stejně jako ostatní platform Helm charts. Provisioner (K8sStorageProvisionervtalkide-be) chart aplikuje.
Důvody pro samostatný git repo (ne modul v talkide-be monorepu):
- Pattern parity s
talkide-worker(ADR-024) — stejná operational story: per-namespace deployment, K8s Secret mount, scoped image, samostatný release cyklus. Konzistence napříč per-namespace komponentami snižuje kognitivní overhead při operacích. - Independent release lifecycle — deploy storage-svc nesouvisí s deploy platform BE.
Bugfix v storage-svc neblokuje rollout platform BE feature a obráceně. Image tagy
(
be#XXXvsstorage-svc#XXX) jsou nezávislé. - Smaller CI blast-radius — change v storage-svc netriggeruje rebuild platform BE (~5–10 CI minut savings per change). Při Free tier limitu 400 CI min/měsíc (viz CLAUDE.md) je to měřitelná úspora.
- Cleaner contract enforcement — žádné “let me just import that DTO class from platform”
shortcuts. HTTP API mezi platform BE → storage-svc a user-app → storage-svc je jediná
smluvní hranice; sdílené DTO se buď duplikují (úmyslně), nebo se balí přes
talkide-storage-sdk(viz níže).
Storage-svc je stateless — stav je v R2 a presigned URL. Restart podu = žádná ztráta dat. Škálování na N replik je triviální pokud bude potřeba (runtime ne-kritické pro alpha).
Spouští se s minimálními resources: ~256 MB RAM request, ~250m CPU request.
talkide-storage-sdk
Kotlin knihovna scaffoldovaná do user-appek. Volá talkide-storage-svc HTTP endpointy.
Abstrahuje presigned URL lifecycle od user-app kódu.
Lokace: dedikovaný Gradle modul v repo talkide-storage-svc (multi-module Gradle setup:
:service pro Spring Boot mikroservis, :sdk pro Kotlin lib publishovaný do Maven repository).
Důvod společného repa pro service + SDK:
- Verzová konzistence DTO contractu — SDK a service sdílí JSON DTOs. Když se kontrakt změní, oba artefakty se versionují společně (jeden commit, jeden release tag). Drift mezi SDK a service je tak strukturálně eliminován.
- Atomická CI změna kontraktu — change v shared DTO se otestuje proti oběma stranám v jedné pipeline (service i SDK build prochází stejnou CI).
- Pattern parity s frameworkovými projekty — analogické např. Spring projektům, které mají
core+starterve stejném repu.
Alternativa zvážena a odmítnuta: SDK jako součást scaffold šablony v talkide-be/templates/...
(podobně jako kotlin-spring template). Odmítnuto — versioning by byl svázán s release platform BE,
ne s release storage-svc kontraktu (špatná závislost).
Publishing: privátní Maven repo repo.talkide.app/maven (MVP: GitLab Package Registry
projektu talkide/talkide-storage-svc, který distribuuje artefakt jak modulu :sdk).
Detaily viz UC-12005.
API contract (talkide-storage-svc)
Presigned upload:
POST /api/v1/storage/presign-upload
{
"key": "avatars/user-123/profile.jpg",
"contentType": "image/jpeg",
"contentLength": 204800
}
200 OK:
{
"uploadUrl": "https://tk-42-todo-list-dev.r2.cloudflarestorage.com/avatars/user-123/profile.jpg?X-Amz-Signature=...",
"fullKey": "avatars/user-123/profile.jpg",
"expiresAt": "2026-05-24T12:30:00Z"
}
Presigned download:
POST /api/v1/storage/presign-download
{
"key": "avatars/user-123/profile.jpg"
}
200 OK:
{
"downloadUrl": "https://tk-42-todo-list-dev.r2.cloudflarestorage.com/avatars/user-123/profile.jpg?X-Amz-Signature=...",
"expiresAt": "2026-05-24T12:30:00Z"
}
Listing:
GET /api/v1/storage/list?prefix=avatars/user-123/
200 OK:
{
"items": [
{
"key": "avatars/user-123/profile.jpg",
"size": 204800,
"lastModified": "2026-05-24T10:00:00Z"
}
]
}
Delete:
DELETE /api/v1/storage/objects/{key} → 204 No Content
Autentizace user-app vůči storage-svc
Storage-svc a user-app pod jsou ve stejném K8s namespace (same-namespace service-to-service). Autentizace probíhá přes shared HMAC secret:
- K8s Secret
storage-svc-sharedv tenant namespace (s hodnotouSTORAGE_SVC_SHARED_SECRET) mountuje jak storage-svc pod, tak user-app pod. - User-app volá storage-svc s
Authorization: HMAC-SHA256 <timestamp>.<signature>headrem. - Storage-svc validuje signaturu a freshness (okno ±30 s) před zpracováním požadavku.
talkide-storage-sdktuto logiku zapouzdřuje — user-app kód volá jensdk.presignUpload(...).
Shared HMAC je úmyslně jednodušší než JWT: storage-svc nemusí znát user identity, jen ověřit že volající sdílí secret (= volá z legitimního podu ve stejném namespace). Oprávnění na R2 jsou dána již tokenem při presigning.
Consequences
Pozitiva
- Hardwarová IAM izolace per tenant. Kompromitace jednoho storage-svc podu = data jednoho tenanta. Žádné code-managed prefix isolation (viz incident #148).
- Pattern parity s ADR-024. Stejná topologie jako worker: lightweight pod per tenant-env namespace, každý s vlastní scoped credentials. Snižuje kognitivní overhead při operacích.
- Free egress. R2 $0.00/GB egress umožňuje media-heavy user-appky bez nevyzpytatelných nákladů. Presigned URL jdou přímo browser→R2, žádný proxy overhead na platform BE.
- Nulový cross-tenant blast-radius. Per-bucket token, per-bucket storage-svc instance — chyba v izolaci musí projít dvěma nezávislými vrstvami (K8s namespace boundary + R2 token scope).
- Per-bucket billing viditelnost. Cloudflare R2 dashboard ukazuje usage per bucket zdarma — bez dodatečné instrumentace je viditelné co který tenant spotřebovává.
- Odolnost vůči provider switchi. Storage-svc je S3-compatible abstrakce. Swap R2→Backblaze B2 nebo AWS S3 vyžaduje jen změnu endpoint URL + credentials, ne přepsání storage-svc kódu.
Negativa a náklady
-
+1 K8s Deployment per tenant-environment namespace. ~256 MB RAM, ~250m CPU baseline per storage-svc pod. Pro N=100 tenantů × 2 prostředí = 200 podů × 256 MB = ~50 GB RAM dedikované storage podům. Mitigace:
minReplicas: 0(scale-to-zero pro neaktivní prostředí) je architektonicky čisté řešení — storage-svc je stateless, cold-start je trivialní (<5 s). -
Provisioning Create Project +2 API volání. Cloudflare API:
create bucket+create scoped token. Oba jsou synchronní a ~100–300 ms → přidávají měřitelnou latenci do Create Project flow. Akceptovatelné pro jednou-za-projekt operaci. -
Nový cloud provider v stacku. Cloudflare R2 vedle DigitalOcean = dvě cloudové závislosti. Operační overhead: monitoring R2 status page, Cloudflare API changelog. Mitigace: R2 je S3-compatible, zkušenosti ze S3 přenositelné.
-
1000-bucket soft cap. Pro N=1000 tenantů lze navýšit přes Cloudflare support (Enterprise tier nebo support request). Viz Migration path níže.
-
Quota enforcement je aplikační zodpovědnost. R2 nemá per-bucket hard quotas. Storage-svc musí implementovat vlastní kvótový counter (Redis nebo control-plane DB) a odmítat presign-upload požadavky po překročení limitu. To je přidaná logika v storage-svc.
Migration path
Nad 1000 tenantů (R2 bucket limit)
Cloudflare R2 default soft cap 1000 buckets/account je navyšitelný přes support request (doloženo pro Enterprise i Business tier zákazníky). Pokud i po navýšení nestačí:
- Multi-account R2 setup: provisioner distribuuje buckety přes N Cloudflare účtů (rotační algoritmus nebo per-region routing). Žádná změna storage-svc kódu — jen provisioner logika a K8s Secret content.
Switch providera
Pokud R2 změní pricing nebo SLA:
- S3 API kompatibilita umožňuje swap na Backblaze B2, AWS S3 nebo jiný S3-compatible provider.
- Změna = update
R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEYa endpoint URL v K8s Secrets + provisioner volání nové API. Storage-svc kód beze změny. - Data migration:
rclone sync r2:bucket s3:new-bucket(standardní nástroj).
Scale-to-zero pro neaktivní prostředí
Storage-svc je stateless → HorizontalPodAutoscaler nebo KEDA s HTTP trigger (scale-to-zero).
Pokud uživatel nenavštíví app po delší dobu, storage-svc pod se automaticky ukončí a restartuje
on-demand. Cold-start < 5 s (Spring Boot lightweight, žádný NFS working tree).
Alternatives Considered
| Alternativa | Důvod odmítnutí |
|---|---|
| DO Spaces + shared bucket + code prefix isolation | Code bug = cross-tenant data leak (viz incident #148 — přesně tento pattern způsobil security incident). Jde přímo proti ADR-023/ADR-024 filozofii “isolation by infra, ne by code”. |
| DO Spaces + per-tenant bucket | 100-bucket hard cap = structural bloker pro škálování. Nelze obejít bez migrace providera. |
| AWS S3 + per-bucket IAM policy | Egress $0.09/GB nevhodný pro media-heavy apps; třetí cloud provider v stacku bez znatelné výhody nad R2. |
| Centrální platform BE proxy (bez storage-svc per namespace) | 1 deployment, 1 master secret nebo N tenantských tokenů — kompromitace = mass leak nebo komplexní secret management. Jde proti ADR-024 filozofii per-tenant izolace. |
| Embedded SDK s přímým R2 přístupem v user-appce | R2 API token by musel žít v user-app podu = Mara-generovaný kód by viděl credentials. Životní cyklus tokenu svázaný s user-app deployem. Nežádoucí. |
| Minio self-host v K8s | Provozní overhead (HA setup, backup, storage class), žádný free-tier model, nutná správa kapacity. R2 je managed řešení bez těchto nákladů. |
storage-svc jako modul v talkide-be monorepu | Release lifecycle by byl svázán s deploy platform BE (storage-svc bugfix čeká na platform BE deploy okno a obráceně). CI rebuild platform BE při každé storage-svc změně = zbytečná spotřeba CI minut (Free tier 400 min/měsíc, viz CLAUDE.md). Žádná hranice contractu — pokušení importovat DTO z platform balíčků by oslabilo HTTP API jako jedinou smluvní vrstvu. Pattern parity s talkide-worker (samostatné repo) převažuje nad mírně jednodušší CI pipeline. |
SDK lib jako součást scaffold šablony v talkide-be/templates/... | SDK by byl versionován s release platform BE namísto s release storage-svc kontraktu. Drift mezi service a SDK by hrozil při každém release. Společný repo talkide-storage-svc (:service + :sdk Gradle multi-module) drží oba artefakty atomicky synchronní. |
Open Questions / Follow-ups
- Bucket quota enforcement. R2 nemá per-bucket hard quotas. Storage-svc musí implementovat vlastní counter — doporučeno Redis counter s TTL (denní/měsíční reset) nebo záznamy v control-plane DB. Design detaily patří do UC-12 implementace.
- Asset transformation. Thumbnail generování, image resize — potenciální sidecar nebo Cloudflare Worker u R2. Záměrně vynecháno z tohoto ADR jako post-alpha rozšíření.
- Public buckets / CDN front. R2 podporuje custom domain + veřejný přístup. Default = private, signed URLs only. Veřejný bucket = záměrné rozhodnutí per-app, ne default. Design patří do budoucího ADR o public asset hosting.
- CORS konfigurace R2 bucketů. Pro browser-direct upload přes presigned URL musí mít R2 bucket správnou CORS policy. Provisioner nastaví při create bucket — konkrétní CORS pravidla (origins, allowed headers) se specifikují v UC-12 implementaci.
References
- ADR-023 — data-plane PgBouncer, schema-per-app, isolation-by-infra filozofie
- ADR-024 — per-namespace worker, gateway-proxy, pattern parity
- UC-12 Managed Storage (sub-UCs UC-12001 až UC-12006) — detailní use case specifikace storage SDK
- GitLab repo
talkide/talkide-storage-svc— Spring Boot mikroservis (:service) + Kotlin SDK lib (:sdk) v Gradle multi-module setupu - GitLab repo
talkide/talkide-infra— Helm chartcharts/storage-svc/aplikovanýK8sStorageProvisionerem - Incident #148 (platform CLAUDE.md) — cross-tenant data leak v shared PVC, motivace per-bucket izolace
- Cloudflare R2 dokumentace
- Cloudflare R2 S3 API kompatibilita
- Cloudflare R2 pricing
Thanks for the feedback.