Při vytvoření projektu (UC-03002) platform talkide-be automaticky provisionuje per-project storage: Cloudflare R2 bucket + scoped API token + K8s Secret v tenant-env namespace + storage-svc Deployment/Service/NetworkPolicy + řádek v app_storage_config. Volání tohoto UC NENÍ samostatný HTTP endpoint — je to interní orchestrace v rámci Create Project use case.
- Pattern parity s
K8sWorkerProvisioner(ADR-024) aK8sEnvironmentProvisioner(UC-10013); formalizováno v ADR-027 (Per-namespace Storage Gateway). - Provisioner implementuje rozhraní
StorageProvisionerse dvěma beanami:CloudflareR2StorageProvisioner— produkční (@Profile("production"))NoopStorageProvisioner— lokální/test (@Profile("!production"))
- Per-namespace topologie: jedna instance storage-svc per
{tenant-slug}-{env-slug}namespace per projekt. Tj. namespace může hostit více storage-svc Deploymentů (jeden per projekt v tom namespace), Service jmenovánístorage-svc-{projectSlug}. - Idempotence: re-volání pro existující projekt nevytváří nový bucket — provisioner detekuje existující
app_storage_configrow a ověří, že K8s resources žijí; chybějící patch-vytvoří.
Provisioner flow
sequenceDiagram
actor CreateProjectUC as Create Project UseCase
participant SP as K8sStorageProvisioner
participant CF as Cloudflare API
participant K8s as K8s API
participant DB as Cluster A (control-plane PG)
CreateProjectUC->>+SP: provision(tenantId, tenantSlug, projectId, projectSlug, envSlug)
SP->>SP: derive bucketName = "talkide-app-{tenantSlug}-{projectSlug}" <br> (truncate + hash if > 63 chars)
SP->>DB: SELECT * FROM app_storage_config WHERE project_id = ?
alt row already exists
SP->>K8s: verify Secret + Deployment + Service + NetworkPolicy exist
alt all live
SP-->>CreateProjectUC: return existing AppStorageConfig (idempotent no-op)
else any K8s resource missing
SP->>K8s: re-apply Helm template (patch)
SP-->>CreateProjectUC: return existing AppStorageConfig
end
end
SP->>+CF: POST /accounts/{accountId}/r2/buckets <br> { name: bucketName, locationHint: "weur" }
alt bucket creation fails (name collision, quota)
CF-->>SP: 4xx error
SP-->>CreateProjectUC: throw StorageProvisioningException
end
CF->>-SP: 200 OK <br> { bucket: { name, creation_date } }
SP->>+CF: POST /accounts/{accountId}/tokens <br> { name, policies: [{ effect: "allow", permission_groups: ["r2-object-read-write"], resources: { bucket: bucketName } }] }
CF->>-SP: 200 OK <br> { token: { id, value }, accessKeyId, secretAccessKey }
SP->>SP: generate appStorageToken = secureRandomHex(32) <br> generate platformInternalToken = secureRandomHex(32)
SP->>+K8s: CREATE Secret "storage-creds" in ns "{tenantSlug}-{envSlug}" <br> { CF_ACCESS_KEY_ID, CF_SECRET_ACCESS_KEY, CF_R2_BUCKET, CF_R2_ACCOUNT_ID, APP_STORAGE_TOKEN, PLATFORM_INTERNAL_TOKEN }
K8s->>-SP: 201 Created
SP->>+K8s: Helm template apply (Deployment + Service + NetworkPolicy) <br> name "storage-svc-{projectSlug}"
K8s->>-SP: applied
SP->>+DB: INSERT INTO app_storage_config (project_id, bucket_name, cf_token_id, app_storage_token_hash, platform_internal_token_hash, quota_bytes, bytes_used, created_at) <br> quota_bytes = NULL (unlimited default)
DB->>-SP: row inserted
SP->>-CreateProjectUC: return AppStorageConfig
DB Schema
Nový Liquibase changeset v talkide-be/src/main/resources/db/changelog/changes/ — produkční fáze (immutable, vždy nový soubor s dalším volným číslem, viz CLAUDE.md). Při psaní tohoto UC je další volný slot pravděpodobně 0053 (před implementací vždy ověř podle aktuálního stavu adresáře).
Tabulka app_storage_config (cluster A — control-plane):
| Sloupec | Typ | Constraints | Poznámka |
|---|---|---|---|
id | BIGSERIAL | PK | Auto-generated |
project_id | BIGINT | NOT NULL, UNIQUE, FK → projects(id) ON DELETE CASCADE | Jeden storage config per projekt |
bucket_name | VARCHAR(63) | NOT NULL, UNIQUE | RFC 1123 compliant, max 63 znaků (R2 limit) |
cf_account_id | VARCHAR(64) | NOT NULL | Cloudflare account ID (denormalizováno pro audit) |
cf_token_id | VARCHAR(64) | NOT NULL | Cloudflare token ID (pro revocation při delete) |
cf_access_key_id | VARCHAR(128) | NOT NULL | R2 access key ID (uložené taky v K8s Secret — DB = source of truth pro reconciliation) |
cf_secret_access_key_enc | TEXT | NOT NULL | R2 secret access key, šifrovaný platform-side AES-GCM klíčem z application-production.yaml |
app_storage_token_hash | VARCHAR(128) | NOT NULL | SHA-256 hash TALKIDE_APP_STORAGE_TOKEN (raw token jen v K8s Secret) |
platform_internal_token_hash | VARCHAR(128) | NOT NULL | SHA-256 hash PLATFORM_INTERNAL_TOKEN (raw token jen v K8s Secret); ověřuje volání platform scheduler → /internal/* na storage-svc (viz UC-12006) |
quota_bytes | BIGINT | NULL (default) | NULL = unlimited (no hard limit, no quota check). Pokud admin nastaví > 0, storage-svc začne v UC-12002 presign-upload odmítat při overshoot. Primary mechanism je snapshot+billing v UC-12006, ne enforcement. |
bytes_used | BIGINT | NOT NULL DEFAULT 0 | Best-effort live čítač; updatovaný storage-svc async callbackem po DELETE (UC-12004) a po PUT-confirm (UC-12002 best-effort path). NENÍ authoritative — authoritative usage drží storage_usage_snapshot ledger (UC-12006). Tento sloupec slouží jen pro fast quota check, když quota_bytes IS NOT NULL. |
created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() |
Indexy:
PRIMARY KEY (id)UNIQUE INDEX uk_app_storage_config_project_id ON app_storage_config(project_id)UNIQUE INDEX uk_app_storage_config_bucket_name ON app_storage_config(bucket_name)
Liquibase XML skeleton:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.0.xsd">
<changeSet id="0053-create-app-storage-config" author="talkide">
<createTable tableName="app_storage_config">
<column name="id" type="BIGSERIAL"><constraints primaryKey="true" nullable="false"/></column>
<column name="project_id" type="BIGINT"><constraints nullable="false" unique="true"
foreignKeyName="fk_app_storage_config_project"
referencedTableName="projects" referencedColumnNames="id"/></column>
<column name="bucket_name" type="VARCHAR(63)"><constraints nullable="false" unique="true"/></column>
<column name="cf_account_id" type="VARCHAR(64)"><constraints nullable="false"/></column>
<column name="cf_token_id" type="VARCHAR(64)"><constraints nullable="false"/></column>
<column name="cf_access_key_id" type="VARCHAR(128)"><constraints nullable="false"/></column>
<column name="cf_secret_access_key_enc" type="TEXT"><constraints nullable="false"/></column>
<column name="app_storage_token_hash" type="VARCHAR(128)"><constraints nullable="false"/></column>
<column name="platform_internal_token_hash" type="VARCHAR(128)"><constraints nullable="false"/></column>
<column name="quota_bytes" type="BIGINT"><constraints nullable="true"/></column>
<column name="bytes_used" type="BIGINT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="created_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()"><constraints nullable="false"/></column>
<column name="updated_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()"><constraints nullable="false"/></column>
</createTable>
<addForeignKeyConstraint baseTableName="app_storage_config" baseColumnNames="project_id"
constraintName="fk_app_storage_config_project"
referencedTableName="projects" referencedColumnNames="id"
onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
Bucket naming algorithm
fun buildBucketName(tenantSlug: String, projectSlug: String): String {
val prefix = "talkide-app-$tenantSlug-"
val maxProjectSlugLen = 63 - prefix.length
if (projectSlug.length <= maxProjectSlugLen) {
return "$prefix$projectSlug"
}
// truncate + 6-char SHA-256 hash suffix pro deterministic uniqueness
val hash = sha256("$tenantSlug-$projectSlug").take(6).lowercase()
val truncated = projectSlug.take(maxProjectSlugLen - 7) // -7 = "-" + 6 hash chars
return "$prefix$truncated-$hash"
}
Příklady:
tenantSlug=popelkam, projectSlug=todo-list→talkide-app-popelkam-todo-list(28 znaků, OK)tenantSlug=acmecorporation, projectSlug=very-long-project-slug-that-goes-over-the-limit→ truncated + hash →talkide-app-acmecorporation-very-long-project-slug--a3f8b2(63 znaků)
Validace v BE před voláním provisioneru:
tenantSlugaprojectSlugjsou už validované na úrovni Create Tenant / Create Project (RFC 1123, reserved slug check).- Výsledný
bucketNamemusí matchovat regex^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$(R2 requirement).
K8s resources (Helm template)
Storage-svc deploy do {tenantSlug}-{envSlug} namespace. Template lives v talkide-infra/charts/storage-svc/.
Deployment storage-svc-{projectSlug}:
apiVersion: apps/v1
kind: Deployment
metadata:
name: storage-svc-{{ .Values.projectSlug }}
namespace: {{ .Values.tenantSlug }}-{{ .Values.envSlug }}
labels:
app.kubernetes.io/name: storage-svc
app.kubernetes.io/instance: {{ .Values.projectSlug }}
talkide.app/tenant: {{ .Values.tenantSlug }}
talkide.app/project: {{ .Values.projectSlug }}
spec:
replicas: 1
strategy: { type: Recreate } # parita s K8sWorkerProvisioner (#275)
selector:
matchLabels:
app.kubernetes.io/name: storage-svc
app.kubernetes.io/instance: {{ .Values.projectSlug }}
template:
metadata:
labels:
app.kubernetes.io/name: storage-svc
app.kubernetes.io/instance: {{ .Values.projectSlug }}
spec:
containers:
- name: storage-svc
image: registry.digitalocean.com/talkide/talkide-storage-svc:{{ .Values.image.tag }}
imagePullPolicy: IfNotPresent
ports:
- { name: http, containerPort: 8080 }
env:
- { name: SPRING_PROFILES_ACTIVE, value: "production" }
- { name: JAVA_OPTS, value: "-Xmx384m -XX:+UseG1GC" }
- name: CF_R2_BUCKET
valueFrom: { secretKeyRef: { name: storage-creds, key: CF_R2_BUCKET } }
- name: CF_R2_ACCOUNT_ID
valueFrom: { secretKeyRef: { name: storage-creds, key: CF_R2_ACCOUNT_ID } }
- name: CF_ACCESS_KEY_ID
valueFrom: { secretKeyRef: { name: storage-creds, key: CF_ACCESS_KEY_ID } }
- name: CF_SECRET_ACCESS_KEY
valueFrom: { secretKeyRef: { name: storage-creds, key: CF_SECRET_ACCESS_KEY } }
- name: APP_STORAGE_TOKEN
valueFrom: { secretKeyRef: { name: storage-creds, key: APP_STORAGE_TOKEN } }
- name: PLATFORM_INTERNAL_TOKEN
valueFrom: { secretKeyRef: { name: storage-creds, key: PLATFORM_INTERNAL_TOKEN } }
resources:
requests: { memory: 256Mi, cpu: 250m }
limits: { memory: 512Mi, cpu: 500m }
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: http }
initialDelaySeconds: 30
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: http }
initialDelaySeconds: 5
Service storage-svc-{projectSlug}:
apiVersion: v1
kind: Service
metadata:
name: storage-svc-{{ .Values.projectSlug }}
namespace: {{ .Values.tenantSlug }}-{{ .Values.envSlug }}
spec:
type: ClusterIP
ports:
- { name: http, port: 80, targetPort: http }
selector:
app.kubernetes.io/name: storage-svc
app.kubernetes.io/instance: {{ .Values.projectSlug }}
NetworkPolicy — accept traffic POUZE z (a) user-app podu v same ns pro user API, (b) platform scheduler podu v talkide ns pro internal endpointy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: storage-svc-{{ .Values.projectSlug }}-ingress
namespace: {{ .Values.tenantSlug }}-{{ .Values.envSlug }}
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: storage-svc
app.kubernetes.io/instance: {{ .Values.projectSlug }}
policyTypes: [Ingress]
ingress:
# (a) user-app v same ns → user-facing API
- from:
- podSelector:
matchLabels:
talkide.app/project: {{ .Values.projectSlug }}
talkide.app/role: user-app
ports:
- { protocol: TCP, port: 8080 }
# (b) platform talkide-be scheduler v ns "talkide" → /internal/* (snapshot UC-12006)
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: talkide
podSelector:
matchLabels:
app.kubernetes.io/component: platform-scheduler
ports:
- { protocol: TCP, port: 8080 }
User-app Deployment musí mít labely talkide.app/project: {projectSlug} a talkide.app/role: user-app (Mara prompt update v UC-12005). Platform scheduler pod (talkide-be) musí mít label app.kubernetes.io/component: platform-scheduler na svém Deploymentu — viz UC-12006 sekce “Scheduler topology”.
Path-based routing v storage-svc doplňuje NetworkPolicy: Spring Security filter ověřuje, že požadavky na /internal/* mají Authorization: Bearer <PLATFORM_INTERNAL_TOKEN>, požadavky na /api/v1/storage/* mají X-Talkide-App-Token: <APP_STORAGE_TOKEN>. Cross-použití tokenů = 401.
K8s Secret storage-creds content
| Key | Value (base64-encoded by K8s) |
|---|---|
CF_R2_BUCKET | bucket name (např. talkide-app-popelkam-todo-list) |
CF_R2_ACCOUNT_ID | Cloudflare account ID |
CF_ACCESS_KEY_ID | R2 access key z scoped tokenu |
CF_SECRET_ACCESS_KEY | R2 secret access key z scoped tokenu |
APP_STORAGE_TOKEN | 32B hex random — user-app ↔ storage-svc HMAC shared secret (chrání /api/v1/storage/*) |
PLATFORM_INTERNAL_TOKEN | 32B hex random — platform talkide-be ↔ storage-svc service-to-service bearer (chrání /internal/*, čte ho výhradně snapshot scheduler v UC-12006) |
User-app Deployment musí mít namountovaný Secret jako env var (UC-12005 scaffold):
env:
- name: TALKIDE_APP_STORAGE_TOKEN
valueFrom: { secretKeyRef: { name: storage-creds, key: APP_STORAGE_TOKEN } }
- name: TALKIDE_APP_STORAGE_URL
value: "http://storage-svc-{{ projectSlug }}.{{ tenantSlug }}-{{ envSlug }}.svc.cluster.local"
Cloudflare API contract (provisioner side)
1. Create R2 bucket:
POST https://api.cloudflare.com/client/v4/accounts/{accountId}/r2/buckets
Authorization: Bearer {CLOUDFLARE_API_TOKEN}
Content-Type: application/json
{
"name": "talkide-app-popelkam-todo-list",
"locationHint": "weur"
}
2. Create scoped R2 token:
POST https://api.cloudflare.com/client/v4/accounts/{accountId}/tokens
Authorization: Bearer {CLOUDFLARE_API_TOKEN}
Content-Type: application/json
{
"name": "r2-talkide-app-popelkam-todo-list",
"policies": [{
"effect": "allow",
"permission_groups": [
{ "id": "<r2-object-read-write-group-id>" }
],
"resources": {
"com.cloudflare.api.account.r2.bucket.{accountId}/talkide-app-popelkam-todo-list": "*"
}
}]
}
Response obsahuje result.value (Cloudflare API token), result.access_key_id, result.secret_access_key (S3 credentials pro scoped bucket access).
3. Master Cloudflare API token (platform-level):
- Permissions:
Account → Workers R2 Storage → Edit+User API Tokens → Edit. - Uložen v K8s Secret
cloudflare-credsvtalkidens, keyapi-token. - Vyzískat:
kubectl get secret -n talkide cloudflare-creds -o jsonpath='{.data.api-token}' | base64 -d.
Internal contract (talkide-be Kotlin)
package com.mddsummer.talkide.features.storage.infrastructure
interface StorageProvisioner {
fun provision(input: StorageProvisionInput): AppStorageConfig
fun deprovision(projectId: Long)
}
data class StorageProvisionInput(
val tenantId: Long,
val tenantSlug: String,
val projectId: Long,
val projectSlug: String,
val envSlug: String,
val quotaBytes: Long? = null, // NULL = unlimited (default); admin override-only
)
data class AppStorageConfig(
val id: Long,
val projectId: Long,
val bucketName: String,
val storageSvcUrl: String, // http://storage-svc-{slug}.{ns}.svc.cluster.local
val quotaBytes: Long?, // NULL = unlimited
val bytesUsed: Long, // best-effort live counter; authoritative usage v storage_usage_snapshot (UC-12006)
)
@Profile("production")
@Component
class CloudflareR2StorageProvisioner(...) : StorageProvisioner { ... }
@Profile("!production")
@Component
class NoopStorageProvisioner(...) : StorageProvisioner {
override fun provision(input: StorageProvisionInput): AppStorageConfig {
// pouze zápis do DB; bucket = "noop-{projectId}", svcUrl = "http://localhost:0"
}
override fun deprovision(projectId: Long) { /* DB delete only */ }
}
Volání z CreateProjectUseCase:
val storageConfig = storageProvisioner.provision(
StorageProvisionInput(tenant.id, tenant.slug, project.id, project.slug, environment.slug)
)
// storageConfig.storageSvcUrl se předá do K8sAppDeployer pro user-app env var
Pozn. ke Spring profilu: Production profil je production (NE prod) — viz CLAUDE.md a incident #194. Všechny @Profile anotace musí používat correct název.
Error handling
| Scénář | Chování |
|---|---|
| Cloudflare API timeout / 5xx | Retry s exponential backoff (3 pokusy, 1s/2s/4s). Po 3 selháních throw StorageProvisioningException; Create Project rollback (transactional). |
| Bucket name collision (DB UNIQUE violation nebo Cloudflare 409) | StorageProvisioningException; FE zobrazí “Storage initialization failed, contact support”. Možný recovery: manuální admin runbook. |
| K8s Secret create fails | Retry 3×; pak rollback (delete bucket + revoke token + throw). |
| Helm template apply fails | Stejně jako K8s Secret create. |
| DB INSERT fails (po úspěšném Cloudflare + K8s) | KRITICKÝ DRIFT — log ERROR, alert PagerDuty, manuální cleanup runbook. Pravděpodobnost minimální (BD INSERT je poslední krok, DB connection failure tu nebývá). |
Provisioner orchestruje rollback v opačném pořadí (LIFO): DB → K8s Helm uninstall → K8s Secret delete → Cloudflare token revoke → Cloudflare bucket delete.
Idempotence
Re-volání provision() pro existující project_id:
- SELECT FROM
app_storage_configWHEREproject_id = ? - Pokud row neexistuje → full flow (jako první volání).
- Pokud row existuje:
- Verify K8s Secret
storage-credsexists in ns. - Verify Deployment
storage-svc-{slug}exists in ns. - Verify Service
storage-svc-{slug}exists in ns. - Verify NetworkPolicy exists in ns.
- Verify Cloudflare bucket exists (HEAD bucket via S3 API).
- Chybějící resources patch-apply (helm upgrade).
- Nikdy nevytvářet nový Cloudflare token ani bucket (perpetual lifecycle, OD-7).
- Return existing
AppStorageConfig.
- Verify K8s Secret
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
nový projekt, žádný existující app_storage_config | provisioner.provision() je volán | Cloudflare bucket vytvořen; scoped token vytvořen; K8s Secret + Deployment + Service + NetworkPolicy v ns vytvořeny; řádek app_storage_config zapsán; vrácen AppStorageConfig s storageSvcUrl |
tenantSlug=popelkam, projectSlug=todo-list | buildBucketName() | vrátí talkide-app-popelkam-todo-list (28 znaků) |
tenantSlug=acmecorporation, projectSlug=very-long-project-slug-over-limit (kombinovaná délka > 63) | buildBucketName() | vrátí název ≤ 63 znaků, končící -{6-char-hash} |
| dva projekty stejného tenanta se stejným pre-truncate prefixem | buildBucketName() pro oba | hashe se liší (deterministic, ale na různých project slugs) — žádná kolize |
app_storage_config existuje, všechny K8s resources žijí | provisioner.provision() je volán (idempotent re-call) | žádné Cloudflare volání; žádné K8s create; vrácen existující AppStorageConfig |
app_storage_config existuje, Deployment chybí (např. manuálně smazán) | provisioner.provision() je volán | helm template re-apply; bucket+token nedotčeny; vrácen existující AppStorageConfig |
| Cloudflare API vrátí 5xx 3× po sobě | provisioner.provision() je volán | throw StorageProvisioningException; Create Project transaction rollback; žádný řádek app_storage_config |
| Cloudflare bucket vytvořen, ale K8s Secret create selže | provisioner.provision() je volán | rollback: revoke Cloudflare token + delete bucket; throw StorageProvisioningException |
| DB INSERT selže po úspěšném Cloudflare + K8s setup | provisioner.provision() je volán | log ERROR, alert; ruční cleanup runbook; StorageProvisioningException propaguje |
| projekt smazán (UC-03007) | provisioner.deprovision(projectId) je volán | bucket emptied (batch delete) + bucket deleted + token revoked + K8s Secret/Deployment/Service/NetworkPolicy smazány + app_storage_config row smazán (CASCADE) |
| deprovision: bucket batch-delete vrátí 5xx | deprovision() je volán | retry 3×; pokud stále selhává, log ERROR + pokračovat dalšími kroky (best-effort, parita s K8sWorkerProvisioner.deprovision) |
Noop profile (!production) | provisioner.provision() je volán | žádné Cloudflare/K8s volání; app_storage_config row vytvořen s bucket_name="noop-{projectId}", storage_svc_url="http://localhost:0" |
| invalid characters v projectSlug (např. uppercase) | buildBucketName() | throw IllegalArgumentException — slug validation je úkol Create Project UC, sem už nesmí dorazit invalid |
| bucket name kolize v Cloudflare (409) | provisioner.provision() je volán | log WARN, retry 3× — pokud stále kolize, throw StorageProvisioningException (může indikovat orphaned bucket z předchozího failed provisioningu) |
default volání bez explicit quotaBytes | provisioner.provision() je volán | app_storage_config.quota_bytes IS NULL (unlimited); UC-12002 quota check je full-skip path |
admin volání s quotaBytes = 10 * 1024 * 1024 * 1024 (10 GB) | provisioner.provision() je volán | app_storage_config.quota_bytes = 10737418240; UC-12002 quota check je aktivní |
| K8s Secret content audit | po úspěšném provisioningu | Secret obsahuje VŠECH 6 keys: CF_R2_BUCKET, CF_R2_ACCOUNT_ID, CF_ACCESS_KEY_ID, CF_SECRET_ACCESS_KEY, APP_STORAGE_TOKEN, PLATFORM_INTERNAL_TOKEN |
| DB row audit po úspěšném provisioningu | SELECT z app_storage_config | platform_internal_token_hash = SHA-256(PLATFORM_INTERNAL_TOKEN); app_storage_token_hash = SHA-256(APP_STORAGE_TOKEN); oba hashe se liší (různé tokeny) |
Out of scope (this UC)
- HTTP endpoint pro re-provisioning (admin-only) — manuální runbook v MVP.
- Multi-region buckets —
locationHint: "weur"je hardcoded; multi-region jako follow-up. - Quota change endpoint —
app_storage_config.quota_byteseditovatelný jen přímou DB úpravou v MVP (default = NULL = unlimited; admin nasazuje hard limit ad-hoc). - Token rotation — perpetual v MVP (OD-7).
- Storage usage snapshot scheduler + admin read endpoint — implementace v UC-12006 (status: Planned, součást MVP).
Thanks for the feedback.