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

@aginix/adonis-vulcan-storage

v0.2.1

Published

Drop-in object-storage HTTP module for AdonisJS: signed upload/download URLs, Lucid-backed metadata, path-based security rules, on top of @adonisjs/drive.

Readme

@aginix/adonis-vulcan-storage

Drop-in object-storage HTTP module for AdonisJS. Signed upload + download URLs, Lucid-backed metadata, path-based security rules. Wraps @adonisjs/drive so consumers never call drive.use() themselves.

What you get

  • A StorageObject Lucid model with built-in disk helpers
    const object = await StorageObject.findOrFail(id)
    const url = await object.getSignedDownloadUrl()      // signed download URL
    await object.deleteFromStorage()                      // remove from disk
  • A ready-made HTTP API mounted at /storage/v1:
    • POST /objects/upload — reserve a path, return a signed upload URL
    • GET /objects/:id — fetch metadata
    • GET /objects/:id/sign — issue a signed download URL
    • DELETE /objects/:id — remove the row + the underlying blob
    • PUT /upload?token=... — proxy upload (auto-engages when the disk can't sign; row id lives in the encrypted token)
    • GET /download?token=... — proxy download (same model)
  • Disk- and path-based security rules with :param and ** placeholders, scoped per Drive disk
  • Migration auto-published via node ace configure — just run migration:run
  • Zero scaffolding required: import StorageObject from the package directly. Subclass only if you want extra columns or relations.

Install

npm install @aginix/adonis-vulcan-storage @adonisjs/drive @adonisjs/lucid
node ace configure @aginix/adonis-vulcan-storage
node ace migration:run

The configure hook publishes three files:

  • config/storage.ts — module config
  • start/storage.ts — routes + security rules (this is where you wire your auth middleware and define which paths users can read/write)
  • database/migrations/{ts}_create_storage_objects_table.ts — the storage_objects table

and adds two entries to adonisrc.ts:

  • '@aginix/adonis-vulcan-storage/storage_provider' provider
  • '#start/storage' preload file

Required setup

After configure you still need to do four things before uploads work end-to-end. The first two are non-negotiable; the last two only matter if you upload text/* payloads or run against an internally-deployed S3.

1. Pick a Drive disk in config/drive.ts

This module never calls drive.use() itself — it always reads a disk name from a row or from config/storage.ts. So you need at least one disk configured via @adonisjs/drive:

// config/drive.ts
export default defineConfig({
  default: env.get('DRIVE_DISK'),
  services: {
    s3: services.s3({
      bucket: env.get('S3_BUCKET'),
      // ... credentials, region, endpoint
    }),
    fs: services.fs({ location: app.makePath('storage'), visibility: 'public' }),
  },
})

2. Set defaultDisk in config/storage.ts

defaultDisk is the Drive disk name used when an upload doesn't pick one explicitly. It must match a key from config/drive.ts. Everything else has sane defaults — see Configuration.

// config/storage.ts
export default defineConfig({
  defaultDisk: env.get('DRIVE_DISK'),
})

3. Wire auth middleware + security rules in start/storage.ts

Without any defineRule() call, the module denies every operation (fail-closed). The simplest viable setup mounts the routes inside your auth() middleware so ctx.auth.user is available to your rule handlers:

import { defineRule } from '@aginix/adonis-vulcan-storage'
import { storageRoutes } from '@aginix/adonis-vulcan-storage/routes'
import { middleware } from './kernel.js'

storageRoutes({
  prefix: 'storage/v1',
  middleware: [middleware.auth()],
})

defineRule('s3/users/:userId/files/**', {
  read:   async ({ auth }, params) => String(auth.user?.id) === params.userId,
  create: async ({ auth }, params) => String(auth.user?.id) === params.userId,
  delete: async ({ auth }, params) => String(auth.user?.id) === params.userId,
  maxDownloadTtlSeconds: 60 * 60,
})

defineRule('*/public/**', {
  read: async () => true,
})

[!important] storageRoutes is imported from @aginix/adonis-vulcan-storage/routes (a subpath), not from the package root. The barrel can't re-export it because @adonisjs/core/services/router runs a top-level await app.booted() that crashes during node ace configure. The publishable start/storage.ts stub already uses the right path; only deviate if you know why.

Rule template format is <disk>/<path>. The disk segment:

  • a literal disk name (e.g. s3) — matches that exact disk
  • :name — captures the disk name into params.name
  • * — matches any disk (params.disk still gets the matched name)

The path segment supports :name (single segment, exposed as params.name) and ** (matches any suffix at the end).

Rules are evaluated in registration order; the first matching template wins. A missing handler for an action denies. Without any rules every action is denied — the module is fail-closed.

4. Disable bodyparser's raw parser if you upload text/* content

Adonis bodyparser's default raw.types is ['text/*'] — every text/html, text/csv, text/plain upload is consumed and stashed as a UTF-8 string before any route handler runs. The storage controller falls back to that cached text, so ASCII-compatible uploads still work, but the round-trip through Buffer.from(str, 'utf8') corrupts non-UTF-8 bytes (TIS-620, Windows-874, Latin-1, mixed-encoding CSVs from legacy Excel exports).

If you upload any text content, override the default in config/bodyparser.ts:

export default defineConfig({
  // ... rest of your config
  raw: { types: [] },   // give the storage proxy route the raw bytes
})

With that off, the proxy upload route reads bytes straight from the request stream — byte-exact regardless of Content-Type.

5. Force proxy mode for internally-deployed S3-compatible disks

Optional, only relevant if you run rustfs / minio / ceph behind an internal endpoint. See Proxy mode for internally-deployed S3-compatible disks.

Paths

Object paths are validated server-side at reservation time. The character set matches flydrive's exactly — anything outside fails fast with E_INVALID_STORAGE_PATH (HTTP 400) before any DB row is created:

  • letters, digits, -, _, !, /, ., and whitespace
  • no leading or trailing /
  • no . / .. segments (path-traversal guard)
  • max 1024 chars
  • no control characters

If your users supply filenames, sanitize on the client. A simple name.replace(/[^A-Za-z0-9\-_!.\s]/g, '_') is enough for the common cases (parens, brackets, unicode, URL specials → _); see apps/demo/inertia/pages/posts/index.tsx for a worked example.

Use it from your code

Model utilities

StorageObject lives behind a subpath export (so the configure hook doesn't drag @adonisjs/lucid / @adonisjs/drive / luxon into its import chain when those peers aren't installed yet):

import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'

const object = await StorageObject.findOrFail(id)

const downloadUrl = await object.getSignedDownloadUrl({ expiresIn: '5 mins' })
const uploadUrl   = await object.getSignedUploadUrl()
const metadata    = await object.getStorageMetadata()
const exists      = await object.existsInStorage()

await object.deleteFromStorage()   // only the blob; keeps the row
await object.delete()              // only the row; keeps the blob

The model picks the right disk based on the object's disk column — no drive.use() needed.

Service-layer orchestration (with authorization)

import storage from '@aginix/adonis-vulcan-storage/services/main'

// Issue a signed download with auth + rule enforcement
const { url, expiresInSeconds } = await storage.requestDownload(ctx, objectId)

// Reserve an upload (validates path, authorizes 'create', upserts the row)
const { object, uploadUrl } = await storage.createUpload({
  ctx,
  path: `users/${ctx.auth.user!.id}/avatar.png`,
  name: 'avatar.png',
  contentType: 'image/png',
})

// Authorize 'delete' + remove row + best-effort blob cleanup
await storage.delete(ctx, objectId)

Configuration

config/storage.ts:

import env from '#start/env'
import { defineConfig } from '@aginix/adonis-vulcan-storage'

export default defineConfig({
  defaultDisk: env.get('DRIVE_DISK'),
  uploadUrlTtl: '15 mins',
  downloadUrlTtlDefault: '15 mins',
  downloadUrlTtlMaxSeconds: 60 * 60 * 24 * 7,
  lastAccessedThrottleHours: 1,
})

| Field | Default | Notes | |---|---|---| | defaultDisk | (required) | Drive disk name used when an upload omits disk. | | uploadUrlTtl | '15 mins' | TTL for signed upload URLs. | | downloadUrlTtlDefault | '15 mins' | Default TTL for signed download URLs. | | downloadUrlTtlMaxSeconds | 60 * 60 * 24 * 7 | Hard ceiling on download URL TTL. Rules can clamp further via maxDownloadTtlSeconds. | | lastAccessedThrottleHours | 1 | Min hours between lastAccessedAt writes per object. Set 0 to disable. | | proxyOnlyDisks | [] | Disk names whose signed URLs aren't reachable from the client (e.g. internal rustfs / minio). Listing a disk here forces proxy mode regardless of the driver's signing capability. |

Customizing

Custom routes

storageRoutes({...}) returns a route group, so you can chain or wrap it:

storageRoutes({
  prefix: 'api/files',
  middleware: [middleware.auth(), middleware.throttle()],
})

Custom controller

storageRoutes({
  controller: () => import('#controllers/my_storage_controller'),
})

Custom service

Register your subclass in your own provider's register() — it overrides the package binding:

import { StorageService } from '@aginix/adonis-vulcan-storage'

class MyStorageService extends StorageService {
  protected resolveUserId(ctx) {
    return ctx._user?.id ?? null   // your auth shape
  }
}

// in your provider:
this.app.container.singleton('storage', () => new MyStorageService())

Custom model (extra columns / relations)

import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from '#models/user'

export default class AppStorageObject extends StorageObject {
  @belongsTo(() => User, { foreignKey: 'createdBy' })
  declare creator: BelongsTo<typeof User>
}

(The storage_objects table already exists; just add columns/FKs via your own migration if needed.)

Content validation

The package doesn't ship content validation — it trusts what your security rules let through. Plug your own logic in at one of three points, picking based on what you can know when:

Claim-based, at reserve time

The create handler in defineRule receives the full request context, so you can reject uploads on the client-supplied contentType and size before any DB row is created or any byte is transferred:

const ALLOWED_MIMES = ['image/png', 'image/jpeg', 'application/pdf']
const MAX_BYTES = 10 * 1024 * 1024

defineRule('s3/users/:userId/posts/**', {
  create: async ({ auth, request }, params) => {
    if (String(auth.user?.id) !== params.userId) return false
    const { contentType, size } = request.body() as { contentType?: string; size?: number }
    if (!contentType || !ALLOWED_MIMES.includes(contentType)) return false
    if (typeof size === 'number' && size > MAX_BYTES) return false
    return true
  },
  read: ...,
  delete: ...,
})

Cheapest by far, but the client can lie about both fields. Use this as the first gate, not the only one.

Server-side, at proxy upload

For the proxy upload path (FS in dev, disks listed in proxyOnlyDisks), subclass StorageService and inspect the buffered body before it's written:

import { StorageService } from '@aginix/adonis-vulcan-storage/storage_service'

class MyStorageService extends StorageService {
  async proxyUpload(token, file) {
    // (Token + row resolution happen inside super.proxyUpload —
    // for finer control, copy that prologue here.)
    if (file.stream || file.rawBody) {
      const body = await readBuffer(file)   // your helper or use a small lib
      if (!magicBytesLookOk(body, file.contentType)) {
        throw new E_STORAGE_AUTHORIZATION()
      }
    }
    return super.proxyUpload(token, file)
  }
}

Wire your subclass via the 'storage' container binding (see Custom service).

This catches real bytes, but only for proxy mode — direct-to-S3 signed uploads (the production hot path) skip your server entirely. Treat this as a dev / staging safety net, not a primary defense.

Post-upload async, universal

Hook into the model's @afterCreate lifecycle and queue a background job that reads the object back from storage:

import { afterCreate } from '@adonisjs/lucid/orm'
import StorageObject from '@aginix/adonis-vulcan-storage/storage_object'
import queue from '#services/queue'

export default class AppStorageObject extends StorageObject {
  @afterCreate()
  static enqueueValidation(row: AppStorageObject) {
    void queue.add('storage:validate', { objectId: row.id })
  }
}
// worker handler
const obj = await AppStorageObject.findOrFail(payload.objectId)
const bytes = await obj.getDisk().getBytes(obj.path)
if (!(await scanClean(bytes))) {
  await obj.deleteFromStorage()   // best-effort; ignores if already gone
  await obj.delete()
  return
}
obj.metadata = { ...obj.metadata, validated: true }
await obj.save()

Works regardless of upload mode (proxy or direct-to-S3), runs off the request path, and the row's metadata.validated flag gives the rest of your app a single fact to check before exposing the object. The tradeoff is the validation window: malicious content sits in storage between upload completion and the job running — never present it to other users until metadata.validated === true.

Recommendation

Combine claim-based (cheap front gate via security rule) with post-upload async (the universal defense). The server-side proxy-upload hook is useful in dev and staging when you don't want to wire a queue, but don't rely on it in production: real uploads will bypass it via signed URLs.

Drivers that can't sign URLs (FS in dev)

The bundled @adonisjs/drive FS driver doesn't support signed URLs — getSignedUploadUrl() / getSignedUrl() throw E_CANNOT_GENERATE_URL. Rather than fail your local dev, the service catches that specific error and mints an encrypted, time-bounded, purpose-scoped token instead, then returns a same-origin URL with the token in the query string. The behavior matches how S3/GCS signed URLs work:

| | Signed mode (S3 / GCS) | Proxy mode (FS) | |---|---|---| | uploadUrl / url returned | external signed URL | /storage/v1/upload?token=... or /storage/v1/download?token=... | | Authorization | URL signature | encrypted token in query (adonis-vulcan-storage:upload / :download purposes) | | Object identity | encoded in the signature | encoded in the encrypted token (no :id URL segment) | | Client headers required | none | none |

The two proxy endpoints are registered outside your middleware group on purpose — the token IS the auth. So <img src="..."> and window.location work directly with no header juggling, and clients (@aginix/vulcan-js) PUT raw bytes the same way they would to a signed S3 URL. The response carries an informational proxied: boolean field, but the SDK doesn't act on it — the URL is self-describing.

[!note] The proxy is a correctness fallback, not a performance choice. For prod, configure S3 / GCS / a signing-capable FS driver — your app server stays out of the data path.

Proxy mode for internally-deployed S3-compatible disks

Auto-fallback only triggers when the driver throws E_CANNOT_GENERATE_URL. An S3-compatible disk (rustfs, minio, ceph) deployed on an internal network signs fine — but the URL it produces points at an internal host the browser can't reach. Tell the module to skip the signed-URL fast path for those disks via proxyOnlyDisks:

export default defineConfig({
  defaultDisk: env.get('DRIVE_DISK'),
  proxyOnlyDisks: ['rustfs'],
})

The disk still works exactly like a signing-capable disk from the SDK's perspective — only the URL it gets back happens to be same-origin with a token. Use this for staging/preview environments where storage isn't publicly exposed.

Errors

All errors are AdonisJS Exception subclasses and render to the right HTTP status automatically.

| Class | Status | Code | |---|---|---| | E_INVALID_STORAGE_PATH | 400 | E_INVALID_STORAGE_PATH | | E_STORAGE_AUTHORIZATION | 403 | E_STORAGE_AUTHORIZATION | | E_STORAGE_OBJECT_NOT_FOUND | 404 | E_STORAGE_OBJECT_NOT_FOUND |

License

Proprietary — see LICENSE.md.