Internal Documentation internal
TalkIDE internal documentation

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:

  1. Storage provider — DO Spaces vs. Cloudflare R2 vs. AWS S3?
  2. Bucket strategy — sdílený bucket s code-managed prefix isolation, nebo per-tenant bucket?
  3. Gateway topology — centrální BE proxy nebo per-namespace storage mikroservis?
  4. 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í:

  1. Prefix-level IAM scope neexistuje. DO Spaces IAM neumí omezit klíč na prefix (/tenant-42/*). Per-bucket access keys existují, ale:
  2. 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

ParametrDO SpacesAWS S3Cloudflare R2
Egress~$0.02/GB$0.09/GB$0.00/GB
Storage$0.02/GB$0.023/GB$0.015/GB
Bucket limit100 (hard)neomezeno1000 (soft)
Per-bucket scoped tokenano (key pair)IAM policyR2 API token scoped na bucket
STS / federated credentialsneanone (nevyžadujeme)
S3 API kompatibilitačástečnánativníplná
Prefix-level IAMneanone (ř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, env devtk-42-todo-list-dev
  • tenant 1, projekt my-saas, env prodtk-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:

  1. Volá Cloudflare API pro vytvoření R2 bucketu (PUT /client/v4/accounts/{id}/r2/buckets/{name}).
  2. Volá Cloudflare API pro vytvoření per-bucket scoped R2 API tokenu (POST /client/v4/accounts/{id}/r2/buckets/{name}/access-keys nebo přes tokens API s bucket policy).
  3. Zapíše K8s Secret storage-svc-r2-{slug}-{env} do tenant-environment namespace.
  4. Aplikuje Helm template (nebo K8s manifest přes fabric8) pro talkide-storage-svc Deployment v tenant namespace — analogicky jako K8sWorkerProvisioner.

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-be a talkide-worker thin seam).
  • Build: vlastní Gradle (Kotlin DSL) setup, vlastní Dockerfile, image registry.digitalocean.com/talkide/talkide-storage-svc:{tag} v DO Container Registry (Professional plan = unlimited repos, viz CLAUDE.md infra reference).
  • CI: vlastní .gitlab-ci.yml s when: 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 (K8sStorageProvisioner v talkide-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#XXX vs storage-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 + starter ve 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-shared v tenant namespace (s hodnotou STORAGE_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-sdk tuto logiku zapouzdřuje — user-app kód volá jen sdk.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. +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).

  2. 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.

  3. 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é.

  4. 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.

  5. 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_KEY a 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

AlternativaDůvod odmítnutí
DO Spaces + shared bucket + code prefix isolationCode 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 bucket100-bucket hard cap = structural bloker pro škálování. Nelze obejít bez migrace providera.
AWS S3 + per-bucket IAM policyEgress $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-appceR2 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 K8sProvozní 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 monorepuRelease 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 chart charts/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

Was this page helpful?

Thanks for the feedback.