platform.storage ist die quota-geprüfte Objekt-Ablage der Plattform. Jeder Tenant-Medien-Write läuft über den zentralen Service; nur er verbucht die Nutzung und erzwingt das Plan-Limit. Rohes R2 fasst du nie an.
Schreiben und Löschen
import { storageService } from "@/lib/storage";
import { StorageQuotaExceededError } from "@reosa/sdk";
const storage = storageService();
// Quota-geprüfter Write; der Key MUSS die tenantId enthalten (SDK-Guard).
await storage.put(ctx, `tenants/${ctx.tenantId}/exports/${id}.pdf`, bytes, "application/pdf");
// Soft-Delete: die Quota wird sofort frei, die Bytes räumt der GC-Job ab.
await storage.remove(ctx, `tenants/${ctx.tenantId}/exports/${id}.pdf`);
// Aktuelle Nutzung fürs UI:
const { usedBytes, quotaBytes, objectCount } = await storage.usage(ctx);put(ctx, key, body, contentType)prüft vor dem Write die Plan-Quota (Trial 5 GB bis 5 TB im größten Plan) und verbucht das Objekt. Ein Key ohnectx.tenantIdwird mitValidationErrorabgelehnt: er könnte über Tenants kollidieren oder die Quota umgehen.remove(ctx, key)ist ein Soft-Delete. Vergiss es nicht beim Löschen von Datensätzen, sonst bleibt die Quota belegt.- Ist der Speicher voll, wirft
puteineStorageQuotaExceededError(code: "STORAGE_QUOTA_EXCEEDED", Details:usedBytes,quotaBytes,addBytes).
Quota-Fehler sauber behandeln
Hintergrund-Massen-Writer (z.B. ein Foto-Sync) fangen den Fehler, stoppen sauber und benachrichtigen die Inhaber:in, statt blind weiterzuladen:
try {
await storage.put(ctx, key, bytes, contentType);
} catch (error) {
if (error instanceof StorageQuotaExceededError) {
await notifications.send(ctx, {
toUserId: ownerId,
source: "tool",
toolId: "onoffice-sync",
severity: "warning",
title: "Speicherplatz voll: Foto-Import angehalten",
bodyMd: "Der Import wurde gestoppt. Schaffe Platz oder wechsle den Plan.",
actions: [{ label: "Speicher verwalten", href: "/settings/billing", variant: "primary" }],
});
return; // sauber stoppen, kein Retry-Loop
}
throw error;
}Bucket-Wahl per Key-Prefix, nie explizit
Caller übergeben nie einen Bucket. Die Wahl passiert zentral anhand des Key-Prefixes: Branding-Assets (tenant-logos/, onboarding-logos/) landen im öffentlichen Branding-Bucket und werden über die Assets-Domain ausgeliefert; alles andere liegt im privaten Files-Bucket und wird über den /api/assets-Proxy serviert.
Dokumentierte Ausnahmen
Zwei Objekt-Familien gehen bewusst nicht über die Tenant-Quota:
- Branding-Assets (Tenant-Logos, Favicons): öffentlich, gehören zur Workspace-Identität, zählen nicht gegen den Speicherplan.
- User-Avatare (
avatars/<userId>/...): user-scoped, nicht tenant-scoped. Das Profilbild gehört der Person und zählt in keine Workspace-Quota.
Alles andere, ausnahmslos jeder Tool-Output, läuft quota-geprüft.
In Modulen: saveToolFile statt roher put
In Modul-Code ist storageService().put verboten (der Reuse-Ratchet bun validate:reuse bricht CI). Tool-Ausgaben, die Nutzer:innen sehen sollen, gehen über den Dateispeicher: saveToolFile erledigt Byte-Write, Quota-Prüfung und den sichtbaren Explorer-Eintrag unter Tools/<Name>/ in einem Aufruf, idempotent per dedupeKey. Die komplette API steht auf der Dateispeicher-Seite.
Der rohe storageService().put bleibt für Plattform-Code, dessen Objekte bewusst nicht im Explorer erscheinen (interne Artefakte, Zwischenstände).
Regeln
- Nie rohes R2, nie Quota selbst rechnen. Ein Aufruf, ein Ort.
- Key enthält immer die
tenantId. Der Guard lehnt alles andere ab. removebeim Löschen aufrufen, damit die Quota wieder frei wird.StorageQuotaExceededErrorbehandeln: stoppen, Owner informieren, nicht blind wiederholen.- Explorer-sichtbare Dateien über
saveToolFile, nicht überput.