@mostajs/storage
v0.1.2
Published
Object storage for Octonet — file metadata in DB (via @mostajs/orm sibling), bytes in object storage (FS / S3-compat). Signed URLs, multi-tenant by account scope. Data access via @mostajs/data-plug (ORM or NET).
Downloads
90
Maintainers
Readme
@mostajs/storage
v0.1.2 — Object storage pour Octonet : métadonnée DB via
@mostajs/data-plug(ORM ou NET), bytes en object storage. Filesystem driver + HTTP routes + repo ORM-backed prêt à l'emploi ; S3-compat / TUS / imgproxy à venir.
Auteur : Dr Hamid MADANI [email protected]
Table des matières
- Concepts
- Architecture
- Installation
- Quick start (Node, sans serveur HTTP)
- Recettes
- Intégration
FileMetaRepoavec@mostajs/orm - Intégration HTTP (
@mostajs/net) - Multi-tenant — politiques
- Sécurité
- Migration FS → S3-compat (v0.2.0+)
- Erreurs typées
- Variables d'environnement
- Tests
- Périmètre v0.1.2 + roadmap
- Troubleshooting
- License
Concepts
| Terme | Définition |
|---|---|
| File | Une row métadonnée en DB ({ id, accountId, bucket, path, size, mimeType, checksum, … }). Le contenu binaire ne vit pas en DB. |
| bucket | Namespace logique (avatars, invoices, media). Unité de policy (mime allowlist, taille max, lifecycle). En mode FS = un sous-dossier de rootDir. |
| path | Path logique dans le bucket, tel que demandé par le code applicatif (ex: me.png). La TenantPolicy le transforme en path driver réel (par défaut : <accountId>/me.png). |
| driver | Implémentation concrète du stockage des bytes (FilesystemDriver, futur S3Driver, …). Une seule interface, plusieurs backends. |
| FileMetaRepo | Repository qui persiste les rows File (DI fournie par le consumer, typiquement adossée à @mostajs/orm). |
| TenantPolicy | Décide où placer un fichier dans le bucket et vérifie l'isolation tenant à la lecture. Par défaut : préfixe <accountId>/<path>. |
| signed URL | URL HMAC qui autorise une opération précise (get / put / delete) sur un objet précis, valide jusqu'à exp. Pour FS / dev / pre-S3-migration. S3 = signed URL native du backend. |
Architecture
┌────────────────────────┐
│ Application cliente │
│ (Browser / NetClient) │
└───────────┬────────────┘
│ HTTP
▼
┌────────────────────────┐
│ @mostajs/net (HTTP) │
│ route handlers │
│ (à venir v0.2.0) │
└───────────┬────────────┘
│
┌───────────────────┼──────────────────┐
│ metadata (row) │ bytes (stream) │
▼ ▼ │
┌───────────────┐ ┌───────────────────┐ │
│ @mostajs/orm │ │ StorageDriver │ │
│ FileMetaRepo │ │ FilesystemDriver │ │
│ (DI consumer)│ │ S3Driver (à venir)│ │
└───────┬───────┘ └─────────┬─────────┘ │
│ │ │
▼ ▼ │
┌───────────────┐ ┌───────────────────┐ │
│ PG / Mongo / │ │ FS / S3 / R2 / │ │
│ SQLite / … │ │ MinIO / B2 │ │
└───────────────┘ └───────────────────┘ │
│
┌──────────────────────────────────────────┐ │
│ signed URL HMAC (driver-agnostique) │◀─┘
│ pour FS / dev / pre-S3 │
└──────────────────────────────────────────┘Principe « tête haute » :
- ✅ Bytes ne traversent jamais l'ORM ⇒ pas de contention session JDBC sur les LOBs (cf. audit doc 06).
- ✅ Métadonnée transactionnelle, bytes idempotents — driver écrit avant la row, rollback si la row échoue.
- ✅ Filtrage tenant non-négociable côté serveur (cohérent avec multi-tenant β
Account.parentd'Octonet).
Cf. Entreprise/Octonet-as-Supabase/06-STORAGE-AUDIT-ETAT-DE-L-ART.md pour l'audit complet (Hibernate / Spring / JPA / Supabase / recommandations transverses).
Installation
npm install @mostajs/storageAucune dépendance runtime obligatoire (seul node:fs, node:crypto, node:stream du stdlib). Les drivers S3 / R2 / B2 (v0.2.0+) ajouteront @aws-sdk/client-s3 en peer optional.
Quick start (Node, sans serveur HTTP)
Le code minimal pour comprendre le flow :
import {
FilesystemDriver,
createFile, openDownload, deleteFile, generateFileId,
type FileMetaRepo, type FileMeta,
} from '@mostajs/storage'
// 1. Driver = backend bytes
const driver = new FilesystemDriver({ rootDir: './data/storage' })
// 2. metaRepo = backend métadonnées (DI). Ici, in-memory pour l'exemple.
const fakeDb = new Map<string, FileMeta>()
const metaRepo: FileMetaRepo = {
async insert(rec) {
const id = generateFileId()
const meta: FileMeta = { id, ...rec, createdAt: new Date() }
fakeDb.set(id, meta)
return meta
},
async findById(id) { return fakeDb.get(id) ?? null },
async findByPath(args) {
for (const m of fakeDb.values()) {
if (m.accountId === args.accountId && m.bucket === args.bucket && m.path === args.path) return m
}
return null
},
async list() { return { items: [...fakeDb.values()] } },
async delete(id) { fakeDb.delete(id) },
}
// 3. Upload
const meta = await createFile(
{ driver, metaRepo, allowedMimeTypes: [/^image\//], maxSizeBytes: 5 * 1024 * 1024 },
{
accountId: 'acc-1',
bucket: 'avatars',
path: 'me.png', // → préfixé en 'acc-1/me.png' par DEFAULT_TENANT_POLICY
body: Buffer.from('PNG bytes'),
mimeType: 'image/png',
},
)
console.log(meta.id, meta.path) // f-XYZ... acc-1/me.png
// 4. Download (stream)
const dl = await openDownload({ driver, metaRepo },
{ accountId: 'acc-1', bucket: 'avatars', path: 'me.png' })
// dl.stream : ReadableStream<Uint8Array>
// dl.size, dl.mimeType, dl.etag, dl.meta
// 5. Delete
await deleteFile({ driver, metaRepo },
{ accountId: 'acc-1', bucket: 'avatars', path: 'me.png' })Dans une vraie app,
metaReposera adossé à@mostajs/orm— voir §6.
Recettes
5.1 Avatar utilisateur (image, ≤ 5 MB)
const config = {
driver, metaRepo,
allowedMimeTypes: [/^image\/(png|jpe?g|webp|avif)$/],
maxSizeBytes: 5 * 1024 * 1024,
}
await createFile(config, {
accountId: session.accountId,
bucket: 'avatars',
path: `${session.userId}.png`,
body: req.body,
mimeType: 'image/png',
ifNotExists: false, // remplacement OK
metadata: { uploadedFromIp: req.ip },
})Comportement attendu :
- Un MIME hors allowlist →
StorageError('invalid_mime')→ HTTP 415. - Taille > 5 MB → bytes écrits puis
StorageError('too_large')+ rollback driver (le fichier partiel est supprimé) → HTTP 413. - Si
ifNotExists: trueet fichier déjà présent →StorageError('access_denied')→ HTTP 403.
5.2 Document partagé via signed URL
Génère un lien partageable de 5 min vers un PDF facture :
import { signPayload, verifySignature } from '@mostajs/storage'
const SECRET = process.env.STORAGE_SIGNING_SECRET! // ≥ 32 bytes recommandé
// → côté serveur, à la demande "share invoice"
const { query, exp } = signPayload(
{ secret: SECRET, defaultTtlSec: 300 },
{ bucket: 'invoices', path: `${accountId}/${invoiceId}.pdf`, op: 'get', sub: userId },
)
const shareUrl = `https://app.example.com/storage/invoices/${accountId}/${invoiceId}.pdf?${query}`
// → envoyé par emailCôté handler HTTP qui sert le download :
// /storage/invoices/:accountId/:filename
import { verifySignature, openDownload } from '@mostajs/storage'
export async function GET(req: Request, { params }: { params: { accountId: string; filename: string } }) {
const url = new URL(req.url)
try {
const { sub } = verifySignature(
{ secret: SECRET },
{
bucket: 'invoices',
path: `${params.accountId}/${params.filename}`,
query: url.searchParams,
expectedOp: 'get',
},
)
// sub = userId qui a généré le lien — utilisable pour audit.
const dl = await openDownload({ driver, metaRepo },
{ accountId: params.accountId, bucket: 'invoices', path: params.filename })
return new Response(dl.stream, {
headers: { 'Content-Type': dl.mimeType, 'Content-Length': String(dl.size) },
})
} catch (e) {
if (e instanceof StorageError) return Response.json({ error: e.code }, { status: STORAGE_ERROR_HTTP[e.code] })
throw e
}
}Garanties :
- Tampering du
path, duopou duexpinvalide la signature (StorageError('signature_invalid')). - Au-delà de
exp→StorageError('signature_expired')(HTTP 410). - Comparaison
timingSafeEqualcôtéverifySignature— pas de timing attack.
5.3 Upload streamé depuis un Request
FilesystemDriver.put accepte Buffer | Uint8Array | ReadableStream<Uint8Array>. Pour ne pas charger le fichier complet en RAM :
// app/api/upload/route.ts (Next.js / Web Request)
export async function POST(req: Request) {
if (!req.body) return new Response('No body', { status: 400 })
const meta = await createFile({ driver, metaRepo, maxSizeBytes: 50 * 1024 * 1024 }, {
accountId: session.accountId,
bucket: 'media',
path: `videos/${randomId()}.mp4`,
body: req.body, // ← ReadableStream, streamé tel quel
mimeType: req.headers.get('content-type') ?? 'application/octet-stream',
size: Number(req.headers.get('content-length') ?? -1),
})
return Response.json({ id: meta.id, path: meta.path })
}Le sha256 est calculé au stream-write — pas de re-read du disque pour le checksum.
5.4 Lister les fichiers d'un user (UI)
const page = await metaRepo.list({
accountId: session.accountId,
bucket: 'media',
prefix: 'videos/',
limit: 50,
})
// page.items : FileMeta[]
// page.nextCursor : string | undefinedLe metaRepo filtre déjà par accountId (DB query) — pas besoin de re-vérifier la tenancy côté Studio.
Note :
driver.list(...)existe aussi (ex: pour synchronisation ou GC d'orphelins), mais ne fait pas l'isolation tenant — c'est le rôle du metaRepo en prod.
5.5 Suppression "tombstone" + audit log
deleteFile est destructif (driver hard delete + DB delete). Pour ajouter un audit log + tombstone soft delete, le consumer wrappe :
import { wrapEmitter, type AuthEventEmitter } from '@mostajs/auth/lib/auth-events' // réutilisable
const audit = wrapEmitter({ emit: (e) => db.auth_events.insert(e) })
async function deleteFileWithAudit(args: { accountId: string; bucket: string; path: string; userId: string }) {
const meta = await metaRepo.findByPath(args)
if (!meta) throw new StorageError('not_found', 'No metadata')
// Soft : on tague la meta plutôt que de supprimer la row.
await metaRepo.softDelete(meta.id, { deletedAt: new Date(), deletedBy: args.userId })
await driver.delete({ bucket: args.bucket, path: meta.path })
audit.emit({
kind: 'storage.delete',
userId: args.userId,
metadata: { fileId: meta.id, bucket: args.bucket, path: args.path, size: meta.size },
})
}softDelete est un ajout consumer-side (pas dans FileMetaRepo v0.1.0) — viendra peut-être en v0.2.0 avec un flag opt-in.
Intégration FileMetaRepo avec @mostajs/orm
v0.1.2 : le schéma
Fileet l'implémentationFileMetaRepoORM-backed sont livrés dans le module. Le consumer ne ré-implémente plus rien à la main — il branchegetStorageRepos()(factory data-plug, mêmes conventions quegetRbacRepos()).
Pré-requis : @mostajs/rbac registered
File.account est une relation many-to-one → Account (cf. @mostajs/rbac/schemas/account.schema.ts). Il faut donc enregistrer rbac AVANT storage sinon la relation pointe une entité inconnue côté ORM.
// app/server/init-data.ts
import { getRbacRepos } from '@mostajs/rbac/server'
import { getStorageRepos } from '@mostajs/storage/server'
export async function initData() {
await getRbacRepos() // enregistre Account + User + Role + …
return await getStorageRepos() // enregistre File, branche FileRepository
}Aiguillage MOSTA_DATA (ORM ou NET) — automatique via data-plug
# .env (un Octonet self-host avec ORM direct)
MOSTA_DATA=orm
DB_DIALECT=postgres
SGBD_URI=postgres://user:pass@host:5432/octonet
# .env (un node consumer derrière le gateway Octonet)
MOSTA_DATA=net
MOSTA_NET_URL=https://octonet.example.com
MOSTA_NET_TRANSPORT=rest
MOSTA_NET_API_KEY=ok_xxxAucun changement de code consumer entre les deux modes — getStorageRepos() retourne le même FileMetaRepo, qu'il pointe la DB locale ou un Octonet distant via @mostajs/net.
Aiguillage MOSTA_ENV (DEV / TEST / PROD) — via @mostajs/config
Toutes les clés storage-spécifiques bénéficient de la cascade :
MOSTA_ENV=DEV
DEV_STORAGE_FS_ROOT=./data/storage-dev
DEV_STORAGE_SIGNING_SECRET=dev-secret-32-bytes-minimum-aaaaa
STORAGE_FS_ROOT=./data/storage # fallback global
STORAGE_DEFAULT_TTL_SEC=300Lecture côté code :
import { getStorageEnv } from '@mostajs/storage/server'
import { FilesystemDriver } from '@mostajs/storage'
const env = getStorageEnv() // résout via @mostajs/config (cascade MOSTA_ENV)
const driver = new FilesystemDriver({ rootDir: env.fsRoot })
console.log(`[storage] profile=${env.profile} root=${env.fsRoot}`)Wire-up complet (Next.js App Router exemple)
// app/api/storage/[bucket]/[...path]/route.ts
import { NextRequest } from 'next/server'
import { FilesystemDriver, createFile, openDownload, deleteFile } from '@mostajs/storage'
import { getStorageRepos, getStorageEnv } from '@mostajs/storage/server'
import { getRbacRepos } from '@mostajs/rbac/server'
import { auth } from '@/auth' // @mostajs/auth side
const env = getStorageEnv()
const driver = new FilesystemDriver({ rootDir: env.fsRoot })
async function getRepo() {
await getRbacRepos()
const { files } = await getStorageRepos()
return files
}
export async function POST(req: NextRequest, { params }: { params: { bucket: string; path: string[] } }) {
const session = await auth(req)
if (!session?.user?.accountId) return new Response('Unauthorized', { status: 401 })
const metaRepo = await getRepo()
const meta = await createFile(
{ driver, metaRepo, maxSizeBytes: env.maxSizeBytes },
{
accountId: session.user.accountId,
bucket: params.bucket,
path: params.path.join('/'),
body: req.body!,
mimeType: req.headers.get('content-type') ?? 'application/octet-stream',
size: Number(req.headers.get('content-length') ?? -1),
},
)
return Response.json({ id: meta.id, path: meta.path, size: meta.size })
}Wire-up alternatif : avec data-plug MOSTA_DATA=net
Le consumer (mobile app, microservice tiers, sandbox) pas connecté à la DB branche le même getStorageRepos() — data-plug le routera vers le gateway Octonet via @mostajs/net :
// app distante, juste consommatrice
process.env.MOSTA_DATA = 'net'
process.env.MOSTA_NET_URL = 'https://octonet.example.com'
process.env.MOSTA_NET_API_KEY = process.env.OCTONET_KEY // apikey scopée
const { files } = await getStorageRepos() // retourne un repo qui fait des HTTP, pas de SQL
const meta = await files.findByPath({ accountId, bucket: 'avatars', path: 'me.png' })C'est l'invariant Octonet : un seul code, deux topologies de déploiement (self-host ORM direct OU consumer-of-Octonet NET gateway).
Mapping interne accountId ↔ account
FileMeta.accountId (DTO public, stable) ↔ File.account (colonne schema, FK Account). Le FileRepository traduit dans les deux sens — le consumer voit toujours accountId, jamais le brut account.
Intégration HTTP (@mostajs/net)
Les route handlers natifs
@mostajs/storage/server/storage-routes.tsarrivent en v0.2.0. En attendant, ajouter manuellement :
// app/api/storage/[bucket]/[...path]/route.ts (Next.js App Router)
import { NextRequest } from 'next/server'
import {
createFile, openDownload, deleteFile,
StorageError, STORAGE_ERROR_HTTP,
} from '@mostajs/storage'
import { driver, metaRepo } from '@/lib/storage'
import { auth } from '@/lib/auth'
function errorResponse(e: unknown) {
if (e instanceof StorageError) {
return Response.json({ code: e.code, message: e.message }, { status: STORAGE_ERROR_HTTP[e.code] })
}
return Response.json({ error: 'internal' }, { status: 500 })
}
export async function GET(req: NextRequest, { params }: { params: { bucket: string; path: string[] } }) {
const session = await auth(req)
if (!session) return new Response('Unauthorized', { status: 401 })
try {
const dl = await openDownload({ driver, metaRepo }, {
accountId: session.accountId,
bucket: params.bucket,
path: params.path.join('/'),
})
return new Response(dl.stream, {
headers: {
'Content-Type': dl.mimeType,
'Content-Length': String(dl.size),
'ETag': dl.etag,
'Cache-Control': 'private, max-age=300',
},
})
} catch (e) { return errorResponse(e) }
}
export async function POST(req: NextRequest, { params }: { params: { bucket: string; path: string[] } }) {
const session = await auth(req)
if (!session || !req.body) return new Response('Unauthorized', { status: 401 })
try {
const meta = await createFile({ driver, metaRepo, maxSizeBytes: 50 * 1024 * 1024 }, {
accountId: session.accountId,
bucket: params.bucket,
path: params.path.join('/'),
body: req.body,
mimeType: req.headers.get('content-type') ?? 'application/octet-stream',
size: Number(req.headers.get('content-length') ?? -1),
})
return Response.json({ id: meta.id, path: meta.path, size: meta.size })
} catch (e) { return errorResponse(e) }
}
export async function DELETE(_req: NextRequest, { params }: { params: { bucket: string; path: string[] } }) {
const session = await auth(_req)
if (!session) return new Response('Unauthorized', { status: 401 })
try {
await deleteFile({ driver, metaRepo },
{ accountId: session.accountId, bucket: params.bucket, path: params.path.join('/') })
return new Response(null, { status: 204 })
} catch (e) { return errorResponse(e) }
}Multi-tenant — politiques
DEFAULT_TENANT_POLICY préfixe par <accountId>/. Pour des cas avancés :
import { type TenantPolicy } from '@mostajs/storage'
// Un bucket par tenant : 'avatars-acc-1', 'avatars-acc-2'
const bucketPerTenant: TenantPolicy = {
resolveDriverPath: ({ requestedPath }) => requestedPath.replace(/^\/+/, ''),
pathBelongsToAccount: () => true, // l'isolation est faite côté driver via le bucket lui-même
}
// → en parallèle, side-effect au boot : driver crée un bucket par tenant.
// Hash bucketing pour répartir les rows : '0a/acc-1/me.png'
const hashBucket: TenantPolicy = {
resolveDriverPath: ({ accountId, requestedPath }) => {
const shard = sha1(accountId).slice(0, 2)
return `${shard}/${accountId}/${requestedPath.replace(/^\/+/, '')}`
},
pathBelongsToAccount: ({ accountId, driverPath }) => driverPath.includes(`/${accountId}/`),
}
// Branchement
await createFile({ driver, metaRepo, tenantPolicy: hashBucket }, args)Important :
pathBelongsToAccountest consulté à la lecture (openDownload) ; unfalselèvetenant_violation. Garder cette fonction simple et déterministe.
Sécurité
| Vecteur | Mitigation v0.1.0 |
|---|---|
| Path traversal (../etc/passwd) | FilesystemDriver refuse les segments .. côté input ET vérifie path.relative(rootDir, resolved) post-resolve. |
| Path absolu | Refusé côté input (path.isAbsolute(seg)). |
| Null byte (a\0b) | Refusé côté input. |
| Symlink escape | Tout chemin résolu hors du rootDir → invalid_path. |
| Replay signed URL | TTL court par défaut (5 min) ; signature lié au path exact ; check exp server-time. |
| Tampering query string | Toute modif (path, op, sub, exp) invalide la signature ; timingSafeEqual empêche le timing attack. |
| MIME spoofing | allowedMimeTypes côté createFile ; en v0.2.0+, on ajoutera un magic-byte sniff (file-type lib) pour refuser un PDF déguisé en image/png. |
| Bombe ZIP / unzip-out-of-disk | Out-of-scope v0.1.0 — le driver écrit ce qu'il reçoit. À ajouter côté consumer si besoin. |
| Cross-tenant read | TenantPolicy.pathBelongsToAccount non-négociable + filtrage accountId dans metaRepo. |
| Bypass via driver.get direct | Le driver est un module privé du backend ; n'exposer que la façade file-store au-dessus, jamais le driver à l'app cliente. |
| Secret signing exposé | STORAGE_SIGNING_SECRET doit être ≥ 32 bytes, en .env, jamais loggé. Côté Octonet : intégrable avec @mostajs/config profile cascade. |
Sous la règle « tête haute » du 01/05/2026 : les vecteurs documentés sont traités dans v0.1.0 ; ceux marqués "out-of-scope" sont des décisions explicites, pas du vapor.
Migration FS → S3-compat (v0.2.0+)
Le contrat StorageDriver est conçu pour permettre de switcher de backend sans rien changer en dehors du fichier qui crée le driver. Quand @mostajs/storage-driver-s3 arrivera (v0.2.0) :
// avant (FS)
const driver = new FilesystemDriver({ rootDir: './data/storage' })
// après (S3-compat — MinIO, R2, B2 ou AWS)
const driver = new S3Driver({
endpoint: process.env.S3_ENDPOINT, // 'https://s3.amazonaws.com' / MinIO URL
region: process.env.S3_REGION ?? 'us-east-1',
bucket: 'octonet-default', // S3 bucket réel
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_KEY!,
})Points de rupture à anticiper aujourd'hui :
- Les
signed-urlsHMAC d'Octonet seront remplacés par les signed URLs natives S3 (presigned URL) — le secret HMAC n'est plus nécessaire en S3-mode. driver.list()en S3 nécessite des appels paginésListObjectsV2; le contratnextCursor(string opaque) couvre déjà ça.- En FS, le sidecar
.meta.jsonstocke le MIME ; en S3, c'est dans les S3 metadata (Content-Type+x-amz-meta-*). La façadefile-storene change pas.
Migration des bytes existants : copy-via-stream du driver FS source vers S3 destination, en réutilisant le metaRepo pour énumérer (pas d'orphelin). Script à venir avec v0.2.0.
Erreurs typées
Tous les codes StorageError, mappés directement en HTTP côté serveur :
| code | HTTP | Signification |
|---|---|---|
| not_found | 404 | Métadonnée absente OU bytes absents côté driver. |
| access_denied | 403 | ifNotExists violé, ou autorisation refusée par le consumer. |
| invalid_path | 400 | Path traversal, null byte, segment vide. |
| invalid_mime | 415 | MIME hors allowedMimeTypes. |
| too_large | 413 | Taille > maxSizeBytes (rollback driver). |
| tenant_violation | 403 | Path ne correspond pas à l'accountId (cross-tenant). |
| signature_invalid | 401 | Signed URL altérée ou mal formée. |
| signature_expired | 410 | Signed URL au-delà de exp. |
| driver_error | 502 | Erreur I/O backend (disk full, S3 down). |
Mapping disponible :
import { STORAGE_ERROR_HTTP } from '@mostajs/storage'
return Response.json({ code: e.code }, { status: STORAGE_ERROR_HTTP[e.code] })Variables d'environnement
| Variable | Utilité | Exemple |
|---|---|---|
| STORAGE_DRIVER | Sélection backend ('fs' / 's3' à venir) | fs |
| STORAGE_FS_ROOT | Pour FilesystemDriver | ./data/storage |
| STORAGE_SIGNING_SECRET | HMAC signed URLs (≥ 32 bytes) | <32+ bytes random> |
| STORAGE_DEFAULT_TTL_SEC | TTL des signed URLs si non spécifié | 300 |
| STORAGE_MAX_SIZE_BYTES | Garde-fou par défaut | 52428800 (50 MB) |
Avec @mostajs/config, ces clés bénéficient de la cascade MOSTA_ENV=DEV → DEV_STORAGE_FS_ROOT.
Tests
npm test # tout (~6 s, 54 assertions)
npm run test:driver # FS driver seul
./test-scripts/run-tests.sh --no-build # skip tsc build
./test-scripts/run-tests.sh --filter signedCouverture (cf. test-scripts/TEST-PLAN.md) :
- T1 — FilesystemDriver (27 assertions) : round-trip, path traversal blindé, atomicité, ifNotExists, list prefix/cursor/limit.
- T2 — Signed URLs (12 assertions) : sign/verify, expiration, op mismatch, tampering, longueur invalide non-crash.
- T3 — file-store façade (15 assertions) : tenant prefix, cross-tenant refus, MIME allowlist, too_large + rollback driver.
Périmètre v0.1.2 + roadmap
| Capability | Statut |
|---|---|
| Types + erreurs typées + mapping HTTP | ✅ v0.1.0 |
| Driver interface | ✅ v0.1.0 |
| FilesystemDriver complet (sidecar meta, atomique, anti-traversal) | ✅ v0.1.0 |
| Signed URLs HMAC | ✅ v0.1.0 |
| Façade file-store + DEFAULT_TENANT_POLICY | ✅ v0.1.0 |
| MIME allowlist + maxSize + rollback | ✅ v0.1.0 |
| HTTP route handlers (server/storage-routes.ts) | ✅ v0.1.1 |
| File schema (@mostajs/orm) + FileRepository | ✅ v0.1.2 |
| getStorageRepos() factory data-plug (ORM | NET) | ✅ v0.1.2 |
| getStorageEnv() cascade @mostajs/config (MOSTA_ENV) | ✅ v0.1.2 |
| Tests (110 assertions, ~12 s) | ✅ v0.1.2 |
| S3-compat driver (@aws-sdk/client-s3) | ⏳ v0.2.0 |
| TUS resumable upload (chunks 5-6 MiB) | ⏳ v0.2.0 |
| Magic-byte MIME sniff (refuse PDF déguisé) | ⏳ v0.2.0 |
| Migration script FS → S3 | ⏳ v0.2.0 |
| imgproxy sidecar (resize / WebP / AVIF) | ⏳ v0.3.0 |
| S3 protocol passthrough (aws s3 sync direct) | ⏳ v0.3.0 |
| 18 polyglot SDK ports | ⏳ post-stabilisation TS/JS |
Conformément à la règle « tête haute » du 01/05/2026 : ✅ = livré, testé, démontrable. ⏳ = décision de scope, pas du vapor.
Troubleshooting
StorageError('invalid_path') quand mon path commence par / — c'est volontaire. Les paths sont logiques, pas absolus. Strip le / de tête avant l'appel.
StorageError('not_found') après un createFile réussi — vérifier que metaRepo.findByPath reçoit bien le path driver-resolved (acc-1/me.png), pas le path utilisateur (me.png). La façade le fait pour toi ; si tu appelles le repo en direct, c'est à toi de préfixer.
fichier .tmp-XXX-YYY orphelin dans rootDir — un crash en pleine écriture. Pas dangereux, mais à nettoyer périodiquement (cron find rootDir -name '*.tmp-*' -mtime +1 -delete).
signature_invalid avec signature copiée depuis un test — vérifier que path, bucket, op au moment de verify sont strictement identiques à ceux de sign. Différence de casse, slash final → invalide.
Lecture lente d'un gros fichier (> 100 MB) — c'est fs.createReadStream qui streame ; côté serveur HTTP, vérifier que tu ne await response.arrayBuffer() pas en aval (re-buffer toute la réponse). Pipe directement le stream dans le Response body.
Cross-process : un node A écrit, un node B ne voit pas immédiatement — atomicité = oui (rename), mais visibilité = au niveau filesystem (atomic rename POSIX). Sur NFS / shared volumes, attendre fsync. En production multi-instance → S3 ou shared FS avec consistance.
License
AGPL-3.0-or-later + commercial — [email protected].
