# @mostajs/storage — fiche LLM
> Stockage d'objets pour Octonet : métadonnée fichier en base (via @mostajs/orm), bytes en object storage (FS / S3-compat), URLs signées, multi-tenant par compte.

- Version: 0.1.2 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
- Chemin: mostajs/mosta-storage · Statut audit: complet (dist/)

## RÔLE
Sépare la métadonnée fichier (table en base, via repo @mostajs/data-plug / ORM) des
bytes (driver de stockage : filesystem aujourd'hui, S3-compat à venir). Fournit les
opérations CRUD haut niveau (`createFile`, `openDownload`, `deleteFile`) avec isolation
multi-tenant garantie (préfixe `<accountId>/`), des URLs signées HMAC, et un jeu de
routes HTTP prêtes pour @mostajs/net. Erreurs typées (`StorageError`) mappables en HTTP.

## SHA256 / INTÉGRITÉ — calculé PAR storage (ne pas refaire ailleurs)
`createFile` calcule et renvoie le **sha256** dans `PutObjectResult.checksum` (hex complet ;
driver FS par défaut, configurable S3) + un `etag`. Les consommateurs (ex. @mostajs/ged pour
le versionnement) doivent **réutiliser ce `checksum`** au lieu de recalculer un hash — c'est la
brique d'intégrité de l'écosystème. (Le hash générique réutilisable hors fichiers vit dans
@mostajs/security `sha256`.)

## CHIFFREMENT AU REPOS — composé via @mostajs/security (NON intégré ici)
Le chiffrement au repos n'est **pas** dans storage (responsabilité unique : octets+méta). Il est
**externalisé** dans **@mostajs/security** et se compose **sans modifier storage** via le wrapper
`encryptedStorage(driver, cipher)` (chiffre AES-256-GCM au `put`, déchiffre au `get`). Clé via
`.env` (`MOSTA_ENCRYPTION_KEY`, souveraineté §11.3). Idem HMAC des URLs signées : primitive
mutualisable dans @mostajs/security.

## INSTALLATION
npm i @mostajs/storage

## EXPORTS
Barrel "." :
- Types métadonnée : FileMeta, ObjectRef, PutObjectResult
- StorageError (classe), STORAGE_ERROR_HTTP (map code → status HTTP)
- Driver : StorageDriver, PutBody, PutOptions, GetObjectResult, StatResult
- file-store : createFile, openDownload, deleteFile, generateFileId,
  DEFAULT_TENANT_POLICY, types FileMetaRepo, TenantPolicy, FileStoreConfig, CreateFileArgs, OpenDownloadResult
- signed-urls : signPayload, verifySignature, types SignedOperation, SignedPayload, SignConfig
- FilesystemDriver (classe), FilesystemDriverConfig
- CachingDriver (classe), CachingDriverConfig, OriginResolver, CacheStatus
  // décorateur cache read-through / origin-pull (enrobe un StorageDriver de base)

## EXPORTS PAR SOUS-CHEMIN
- "." → tous les types + helpers ci-dessus (sauf serveur/ORM)
- "./server" → FileSchema, FileRepository, getStorageRepos, resetStorageRepos, StorageRepos, getStorageEnv, StorageEnv, StorageDriverKind
- "./types" → FileMeta, ObjectRef, PutObjectResult, StorageError, STORAGE_ERROR_HTTP
- "./lib/file-store" → createFile, openDownload, deleteFile, generateFileId, DEFAULT_TENANT_POLICY
- "./lib/signed-urls" → signPayload, verifySignature
- "./lib/driver" → contrat StorageDriver et types associés
- "./lib/drivers/filesystem" → FilesystemDriver, FilesystemDriverConfig
- "./lib/drivers/caching" → CachingDriver, CachingDriverConfig, OriginResolver, CacheStatus
- "./lib/local-assets" → readLocalAsset, dirOfFileUrl, joinPath, mimeType, LocalAsset  (server-only ; sert des FICHIERS LOCAUX)
- "./lib/local-server" → createLocalServer, LocalServerConfig, LocalRouteHandler  (server-only ; serveur HTTP de fichiers locaux, node:http interne)
- "./lib/repos-factory" → getStorageRepos, resetStorageRepos
- "./lib/env" → getStorageEnv
- "./schemas/file" → FileSchema (EntitySchema @mostajs/orm)
- "./repositories/file" → FileRepository (FileMetaRepo backé par ORM)
- "./server/storage-routes" → createStorageRoutes, StorageRoutes, StorageRoutesConfig, StorageSession, SessionResolver

## API — SIGNATURES
- function createFile(config: FileStoreConfig, args: CreateFileArgs): Promise<FileMeta>
- function openDownload(config: FileStoreConfig, args: { accountId; bucket; path: string }): Promise<OpenDownloadResult>
- function deleteFile(config: FileStoreConfig, args: { accountId; bucket; path: string }): Promise<void>
- function generateFileId(): string
- function signPayload(config: SignConfig, payload: Omit<SignedPayload,'exp'> & { expiresInSec? }): { signature: string; exp: number; query: string }
- function verifySignature(config: SignConfig, args: { bucket; path: string; query: URLSearchParams; expectedOp: SignedOperation }): { sub?: string }
- class FilesystemDriver implements StorageDriver  (constructor(config: { rootDir: string }))
- class CachingDriver implements StorageDriver  (constructor(config: CachingDriverConfig))
    // get() = read-through (miss/périmé → pull origine → cache) ; put/delete/exists/stat/list délégués au base
    // + refresh(ref): Promise<PutObjectResult> (pull forcé) ; prefetch(ref): Promise<CacheStatus> (pré-chauffe sans bytes)
    // get().metadata['x-cache'] ∈ CacheStatus
- function getStorageRepos(): Promise<StorageRepos>   // { files: FileMetaRepo }
- function resetStorageRepos(): void
- function getStorageEnv(): StorageEnv
- class FileRepository implements FileMetaRepo  (constructor(dialect: IDialect))
- function createStorageRoutes(config: StorageRoutesConfig): StorageRoutes
- const FileSchema: EntitySchema
- class StorageError extends Error { code: StorageErrorCode; details? }
- const STORAGE_ERROR_HTTP: Record<StorageError['code'], number>

## TYPES CLÉS
- FileMeta { id; accountId; bucket; path: string; size: number; mimeType: string;
    checksum?: string|null; createdAt: Date|string; updatedAt?; metadata?: Record<string,string>|null }
- ObjectRef { bucket: string; path: string }
- PutObjectResult { ref: ObjectRef; size: number; etag: string; checksum: string }
- StorageError.code : 'not_found' | 'access_denied' | 'invalid_path' | 'invalid_mime'
    | 'too_large' | 'tenant_violation' | 'signature_invalid' | 'signature_expired' | 'driver_error'
- StorageDriver { id; put; get; delete; exists; stat; list }  // paths logiques <bucket>/<path>
- CachingDriverConfig { base: StorageDriver; origin: OriginResolver; ttlMs?=0; offline?=false; staleOnError?=true; defaultMimeType?; headers?; fetchImpl?; timeoutMs?=15000 }
- OriginResolver: (ref: ObjectRef) => string | URL | null | undefined   // null ⇒ objet purement local (pas d'origine)
- CacheStatus: 'HIT'|'MISS'|'REFRESH'|'FORCED'|'STALE-OFFLINE'|'STALE-FALLBACK'|'SKIP'
- FileStoreConfig { driver: StorageDriver; metaRepo: FileMetaRepo; tenantPolicy?; maxSizeBytes?=50MB; allowedMimeTypes? }
- CreateFileArgs { accountId; bucket; path: string; body: PutBody; mimeType: string; size?; metadata?; ifNotExists? }
- TenantPolicy { resolveDriverPath(args): string; pathBelongsToAccount(args): boolean }  // défaut DEFAULT_TENANT_POLICY = préfixe <accountId>/
- SignedPayload { bucket; path: string; exp: number; op: 'get'|'put'|'delete'; sub? }
- SignConfig { secret: string; defaultTtlSec? }
- StorageRoutesConfig { driver; metaRepo; tenantPolicy?; authResolver: SessionResolver; maxSizeBytes?; allowedMimeTypes?; signingSecret?; defaultSignedUrlTtlSec? }
- StorageEnv { profile; driver: 'fs'|'s3'; fsRoot: string; signingSecret?: string; defaultTtlSec: number; maxSizeBytes: number }

## PATTERN
```ts
import { FilesystemDriver, createFile, openDownload } from '@mostajs/storage';
import { getStorageRepos } from '@mostajs/storage/server';
// import { getRbacRepos } from '@mostajs/rbac/server'  // prérequis : enregistre Account, User…

const { files } = await getStorageRepos();
const config = { driver: new FilesystemDriver({ rootDir: '/var/data/storage' }), metaRepo: files };

const meta = await createFile(config, {
  accountId: 'acc_123', bucket: 'avatars', path: 'me.png',
  body: bytes, mimeType: 'image/png',
});

const dl = await openDownload(config, { accountId: 'acc_123', bucket: 'avatars', path: 'me.png' });
// dl.stream : ReadableStream<Uint8Array>
```

## PATTERN — CACHE READ-THROUGH (CachingDriver)
> Besoin : servir des blobs distants (tuiles carto, avatars d'un IdP, assets miroir) en les
> mettant en cache localement, l'origine n'étant contactée qu'au miss / à la péremption / au refresh.
> NE PAS réécrire un cache fichier à la main (DEVRULES §0/§10) — composer ce driver.
```ts
import { FilesystemDriver, CachingDriver } from '@mostajs/storage';
// ou import { CachingDriver } from '@mostajs/storage/lib/drivers/caching';

// Enrobe n'importe quel StorageDriver de base. `origin` traduit ref → URL amont.
const tiles = new CachingDriver({
  base: new FilesystemDriver({ rootDir: '/var/data/tiles' }),
  origin: ({ path }) => `https://tile.openstreetmap.org/${path}`,   // path = 'z/x/y.png'
  ttlMs: 30 * 86_400_000,          // re-pull si > 30 j (0 = jamais)
  offline: false,                  // true → 100 % local, jamais de réseau
  staleOnError: true,              // origine KO + copie présente → sert le périmé
  headers: { 'User-Agent': 'mon-app/1.0 (contact@exemple)' },  // policy d'origine
});

// Lecture : miss → pull origine + cache ; frais → HIT ; périmé → REFRESH.
const r = await tiles.get({ bucket: 'osm', path: '11/1018/734.png' });
r.metadata['x-cache']; // 'HIT' | 'MISS' | 'REFRESH' | 'STALE-OFFLINE' | 'STALE-FALLBACK'
const bytes = Buffer.from(await new Response(r.stream).arrayBuffer());

await tiles.refresh({ bucket: 'osm', path: '11/1018/734.png' });   // mise à jour FORCÉE
await tiles.prefetch({ bucket: 'osm', path: '11/1018/734.png' });  // pré-chauffe (warmer), sans bytes
```
> Pilotage par `.env` côté app (DEVRULES §10#3) : lire `ttlMs`/`offline`/`headers` via `@mostajs/config`,
> jamais en dur. La sémantique métier (URL d'origine) reste dans l'appelant, pas dans storage.

## PATTERN — SERVIR DES FICHIERS LOCAUX (local-assets / local-server)
> Frontière écosystème : transport vers un DISTANT → @mostajs/net ; service de FICHIERS LOCAUX
> (disque/cache, HTTP inclus) → @mostajs/storage. Évite à une app d'importer node:fs/path/url/http.
```ts
import { createLocalServer } from '@mostajs/storage/lib/local-server'
import { dirOfFileUrl, joinPath } from '@mostajs/storage/lib/local-assets'

const root = dirOfFileUrl(import.meta.url)
const server = createLocalServer({
  rootDir: root,                                 // sert root/** (index.html, js, css…)
  handle: async (req, res, url) => {             // routes custom AVANT le statique
    if (url.pathname === '/tiles/1/2/3.png') { /* … cache … */ res.end(bytes); return true }
    return false
  },
})
server.listen(4502, '127.0.0.1')                 // l'app n'importe PAS node:http
// readLocalAsset(root, '/x.css') → { bytes, mimeType } | null (404) ; '..' → StorageError invalid_path
```

## DÉPEND DE
- @mostajs/orm (>=1.13.0) — repo ORM (FileRepository, FileSchema), via "./server" / "./repositories/file"
- @mostajs/data-plug (>=1.1.0) — résolution du dialecte d'accès aux données (ORM ou NET)
- @mostajs/config (>=1.0.0) — configuration
- @mostajs/rbac (peer optionnel, >=2.4.0) — Account/User pour l'isolation tenant

## PIÈGES
- Ordre d'écriture garanti : driver d'abord, puis DB (jamais de row sans bytes ; un
  orphelin côté driver est toléré). `deleteFile` : driver d'abord, puis DB.
- Isolation tenant : `path` passé à `createFile` ne doit PAS contenir le préfixe tenant —
  il est ajouté par la `TenantPolicy` (défaut : `<accountId>/`).
- `getStorageRepos()` suppose que les repos RBAC (Account, User…) sont déjà enregistrés —
  appeler `getRbacRepos()` avant.
- `getStorageEnv().signingSecret` peut être `undefined` : l'app doit alors injecter le
  secret HMAC explicitement aux fonctions signed-url.
- `verifySignature` lève `StorageError` ('signature_invalid' / 'signature_expired') —
  ne renvoie pas de booléen ; comparaison en temps constant.
- Drivers livrés : FilesystemDriver (bytes sur FS) + CachingDriver (décorateur read-through/origin-pull).
  S3-compat / TUS / imgproxy = roadmap.
- CachingDriver : `get` lève `StorageError` ('not_found' si offline+absent ou origine 404 ;
  'driver_error' si origine 5xx et pas de copie). Avec `staleOnError` (défaut), une copie présente
  est servie malgré une origine KO. La déduplication des pulls concomitants est intra-process seulement.
- CachingDriver `offline:true` : `get` renvoie TOUJOURS le label `x-cache: STALE-OFFLINE` (jamais `HIT`)
  même si la copie est « fraîche » par TTL — l'origine n'a pas pu être vérifiée hors-ligne.
- **Sécurité path traversal** (`readLocalAsset` / `createLocalServer`) : tout segment `..` est REJETÉ →
  `readLocalAsset` lève `StorageError('invalid_path')`, `createLocalServer` répond **403** (et non un 404
  silencieux). NB : un `..` nu / `%2e%2e` est normalisé par les clients HTTP (n'atteint pas le serveur) ;
  la détection vise les formes qui l'atteignent (ex. `..%2f`, ou appel direct de `readLocalAsset`).
  Ne JAMAIS désactiver cette vérification (pas d'option de bypass).

## RÉFÉRENCES
- README.md (concepts, recettes, intégration ORM/HTTP, sécurité, variables d'env)
- docs/DESIGN-CACHING-DRIVER.md (conception du CachingDriver — API, algo, tests)
- CHANGELOG.md (historique des versions)
- index.ts, server.ts, lib/, repositories/, schemas/, server/storage-routes.ts
