# Storage SDK

<Lead>
**`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.
</Lead>

## Schreiben und Löschen

```typescript
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 ohne `ctx.tenantId` wird mit `ValidationError` abgelehnt: 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 `put` eine **`StorageQuotaExceededError`** (`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:

```typescript
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](/dev/sdk/files).

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.
- **`remove` beim Löschen aufrufen**, damit die Quota wieder frei wird.
- **`StorageQuotaExceededError` behandeln:** stoppen, Owner informieren, nicht blind wiederholen.
- **Explorer-sichtbare Dateien über [`saveToolFile`](/dev/sdk/files)**, nicht über `put`.
