npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.

License

Auteur : Dr Hamid MADANI [email protected]


Table des matières

  1. Concepts
  2. Architecture
  3. Installation
  4. Quick start (Node, sans serveur HTTP)
  5. Recettes
  6. Intégration FileMetaRepo avec @mostajs/orm
  7. Intégration HTTP (@mostajs/net)
  8. Multi-tenant — politiques
  9. Sécurité
  10. Migration FS → S3-compat (v0.2.0+)
  11. Erreurs typées
  12. Variables d'environnement
  13. Tests
  14. Périmètre v0.1.2 + roadmap
  15. Troubleshooting
  16. 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.parent d'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/storage

Aucune 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, metaRepo sera 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: true et 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 email

Cô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, du op ou du exp invalide la signature (StorageError('signature_invalid')).
  • Au-delà de expStorageError('signature_expired') (HTTP 410).
  • Comparaison timingSafeEqual cô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 | undefined

Le 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 File et l'implémentation FileMetaRepo ORM-backed sont livrés dans le module. Le consumer ne ré-implémente plus rien à la main — il branche getStorageRepos() (factory data-plug, mêmes conventions que getRbacRepos()).

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_xxx

Aucun 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=300

Lecture 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 accountIdaccount

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.ts arrivent 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 : pathBelongsToAccount est consulté à la lecture (openDownload) ; un false lève tenant_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 rootDirinvalid_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-urls HMAC 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és ListObjectsV2 ; le contrat nextCursor (string opaque) couvre déjà ça.
  • En FS, le sidecar .meta.json stocke le MIME ; en S3, c'est dans les S3 metadata (Content-Type + x-amz-meta-*). La façade file-store ne 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 signed

Couverture (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].