Storage

Files & blobs. Local or S3.

createStorage() returns one driver-agnostic Storageput, get, getText, exists, delete, list, and url. The local driver writes under a base directory (path-traversal-proof); the S3 driver targets any S3-compatible store over fetch + SigV4. Your app depends only on the interface, so filesystem ↔ S3 is a config change.

Setup #

createStorage(config) selects a driver by its driver discriminant — local (the default) or s3. The local driver just needs a base directory.

import { createStorage } from '@apex-stack/core/server'

const storage = createStorage({ driver: 'local', dir: 'storage/app' })

Read, write, list #

put accepts a string or Uint8Array (with an optional contentType). get returns bytes or null; getText decodes to a string. delete is idempotent, and list returns { path, size } entries, optionally under a prefix.

await storage.put('docs/a.txt', 'hello world', { contentType: 'text/plain' })

await storage.exists('docs/a.txt')   // → true
await storage.getText('docs/a.txt')  // → 'hello world'
await storage.get('docs/a.txt')      // → Uint8Array | null

await storage.put('bin/blob', new Uint8Array([0, 1, 2, 255]))  // binary too

await storage.list('docs/')  // → [{ path: 'docs/a.txt', size: 11 }, …]
await storage.delete('docs/a.txt')  // idempotent — deleting a missing key is a no-op

Public & signed URLs #

url(path) with no options returns a plain public URL; pass { expiresInSeconds } for a time-limited, tamper-checked signed URL. The local driver signs with a signingSecret (it throws if you ask for a signed URL without one).

const storage = createStorage({
  driver: 'local',
  dir: 'storage/app',
  signingSecret: process.env.APEX_STORAGE_SECRET,
})

await storage.url('img/pic.png')                          // → '/storage/img/pic.png'
await storage.url('img/pic.png', { expiresInSeconds: 3600 })  // → /storage/img/pic.png?sig=…&exp=…

A signed URL carries sig + exp; the server verifies both — a tampered path, a tampered signature, the wrong secret, or a past expiry all fail closed.

S3 & compatible #

The S3 driver speaks SigV4 over fetch — no AWS SDK. url() with an expiry returns a presigned GET; without one, a plain object URL. Point endpoint at MinIO (or any S3-compatible store) to switch to path-style addressing.

const storage = createStorage({
  driver: 's3',
  bucket: 'my-bucket',
  region: 'eu-west-1',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
})

await storage.put('avatars/1.png', bytes, { contentType: 'image/png' })
await storage.url('avatars/1.png', { expiresInSeconds: 900 })  // SigV4 presigned GET

// Custom endpoint → path-style addressing (MinIO, R2, …)
const minio = createStorage({ driver: 's3', bucket, region, accessKeyId, secretAccessKey,
  endpoint: 'https://minio.example.com' })

Path safety #

Every key is normalized and confined to the base directory. A .. segment that would escape the root is rejected with a StoragePathError — while a .. that stays inside resolves normally.

Fail-closed on traversal

storage.put('../escape.txt', …) and storage.get('../../etc/passwd') both throw StoragePathError — user-supplied keys can never read or write outside the storage root.