Agora
Dashboard

Programmatic API

DashboardService, DashboardError, MediaManagerLike and the JSON contract types — the console's logic as a plain, framework-free object you can call from your own routes, plus the response shapes shared by server and SPA.

The console's provider is a thin HTTP shell. Every read and every action it performs lives in DashboardService — a plain class, no AdonisJS import anywhere in it, expressed over a structural view of the media manager. That is what makes the console's logic unit-testable without booting an app, and it is also what lets you reuse it.

Reach for this when you want the console's behaviour under your own routes: a bespoke admin screen, an internal API that lists buckets, a CLI that sweeps a prefix. You get the real implementation — the same paging, the same cross-disk transfer engine, the same guards — instead of a re-derivation of it.

import { DashboardService, DashboardError } from '@adonis-agora/media/dashboard'
import type { MediaManagerLike, DashboardServiceOptions } from '@adonis-agora/media/dashboard'

Constructing one

start/routes.ts
import { MediaManager } from '@adonis-agora/media'
import { DashboardService } from '@adonis-agora/media/dashboard'

const media = await app.container.make(MediaManager)

const dashboard = new DashboardService(
  media,                                    // any MediaManagerLike
  { diskNames: ['s3', 'backups'], actions: true },
  [documentInsightProvider],                // optional — ObjectInsightProvider[]
)

Three arguments:

  1. the manager — typed MediaManagerLike, not MediaManager. The real manager satisfies it; so does a hand-rolled fake (see below).
  2. DashboardServiceOptions{ diskNames, actions }. The first disk name (or the manager's default) is the default disk; actions: false makes every mutating method throw 403.
  3. insightProviders — the object-insight providers, defaulting to [].

MediaManagerLike

The exact slice of MediaManager the console depends on — deliberately small, so faking it in a test is a few lines:

interface MediaManagerLike {
  readonly storage: { readonly defaultDisk: string; disk(name?: string): Disk }
  readonly hasResumable: boolean
  readonly resumable: {
    list(filter?: { disk?: string; keyPrefix?: string }): Promise<UploadSession[]>
    abort(id: string): Promise<void>
    listParts(id: string): Promise<MultipartPart[]>
  }
  readonly store: {
    list(options?: MediaListOptions): Promise<MediaListPage>
    find(id: string): Promise<MediaRecord | null>
    delete(id: string): Promise<void>
  }
}

Note what is not there: nothing about attaching, converting or transforming. The console reads and moves objects; it never creates media.


The methods

Reads

MethodReturnsNotes
topology()Topology{ disks, hasUploads, actions } — the capability probe the SPA enables affordances from. Synchronous.
disks()DiskListResponseEach exposed disk with its DiskCapabilities. A non-extended disk reports list: false and is not browsable. Synchronous.
objects(disk, { prefix?, after?, first? })ObjectListResponseOne page of delimiter listing: folders (common prefixes) + files, plus the cursor envelope (nextCursor / hasNext). See Cursor pagination.
object(disk, key)ObjectDetailResponseMetadata plus a signed URL, 300s TTL.
objectStream(disk, key){ stream, contentType, size }The raw bytes, for streaming through your own route.
objectInsights(disk, key)ObjectInsightsResponseRuns every registered provider concurrently, sanitizes the links, skips (and logs) any that throw. { insights: [] } when none are registered.
uploads({ disk?, prefix? })UploadListResponseIn-progress resumable sessions. { uploads: [] } — not an error — when no session store is configured.
uploadDetail(id)UploadDetailResponseOne session plus its recorded parts. 404 for an unknown id, and 404 when no session store exists.
collections({ collection?, ownerType?, ownerId?, prefix?, after?, first? })CollectionListResponseDelegates straight to MediaStore.list — no bespoke query. Cursor-paginated.
collectionsSummary()CollectionsSummaryResponsePer-collection count + total bytes.
mediaRecord(id)MediaDetailResponseThe record plus a signed URL per conversion.

Why the summary is bounded

MediaStore has no aggregate query — a store SPI that demanded GROUP BY would rule out the in-memory driver and any non-SQL backend. So collectionsSummary walks list() in pages of 200 and sums client-side, stopping at 5,000 records. Past that the rollup is partial rather than unbounded: a very large library degrades to an approximate-but-instant summary instead of a full-table scan behind a spinner.

Cursor pagination

objects and collections take { after?, first? } and return a cursor page — the interface every @adonis-agora/* library paginates through (mirroring @adonis-agora/filter's CursorParams / CursorPage structurally; there is no dependency on that package).

let after: string | undefined
do {
  const page = await dashboard.objects('s3', { prefix: 'photos/', first: 100, ...(after ? { after } : {}) })
  handle(page.folders, page.files)
  after = page.nextCursor ?? undefined
} while (after !== undefined)

Forward-only

The backend is S3's ListObjectsV2 continuation token (and, for collections, an equally forward-only createdAt/id keyset) — an opaque handle to what comes next, with no inverse and no offset. So before / last are not part of the input here, and prevCursor / hasPrev in the response are always null / false. The fields stay so the page is the ecosystem's CursorPage rather than a near-miss of it. The cursor is opaque: hand nextCursor back as after, never parse it.

collections returns CursorPage<MediaEntry> verbatim (items + the envelope). objects carries the same envelope but keeps two payload arrays — folders (common prefixes) and files — because a delimiter listing yields both in one page and collapsing them into a single items array would lose the distinction; its type is therefore CursorPageInfo & { folders, files }.

Actions

Every method below calls assertActions() first and throws DashboardError(403) when the service was built with actions: false.

MethodDoes
putObject(disk, key, body, contentType?)Writes an object from an async byte iterable. Buffered, so capped at 100 MB (413 past it) — larger files belong on the TUS path.
copy({ disk, from, to, toDisk? })Same-disk native server-side copy, or cross-disk streamed get→put (capped at 100 MB per object).
move({ disk, from, to, toDisk? })The same transfer, then deletes the source.
remove({ disk, keys })Batched deleteMany. An empty array is a no-op.
createFolder({ disk, prefix })Writes a zero-byte marker at <prefix>/.
deleteFolder({ disk, prefix })Recursive sweep + delete, paginated at 1,000 keys per page.
copyFolder / moveFolder({ disk, from, to, toDisk? })Recursive relocation, preserving each key's relative path.
abortUpload(id)Discards a resumable session (native multipart + buffered parts) and drops its record.
deleteMediaRecord(id)Deletes the MediaRecord row. Does not touch the underlying disk object — that stays where it is.

DashboardError

One error type, carrying the HTTP status the provider surfaces verbatim:

class DashboardError extends Error {
  constructor(message: string, readonly status: number)
}

400 for a missing/invalid argument, 403 for an actions-gated call on a read-only service, 404 for an unknown disk / record / session, 413 for an oversized upload. Anything else that escapes is a genuine bug and reaches your handler as-is — the provider maps a non-DashboardError to 500.


Using it in your own route

start/routes.ts
router
  .get('/admin/storage/objects', async ({ request, response }) => {
    try {
      return await dashboard.objects(request.input('disk'), {
        prefix: request.input('prefix', ''),
        after: request.input('after'),
        first: 100,
      })
    } catch (error) {
      if (error instanceof DashboardError) {
        return response.status(error.status).json({ error: error.message })
      }
      throw error
    }
  })
  .use([middleware.auth(), middleware.admin()])

Note the shape of that error handling — it is exactly what the provider does internally, and it is the whole contract: catch DashboardError, use its status, rethrow everything else.


The JSON contract types

Every response shape the API returns is exported as a type, framework-free (no DOM, no Node), so the same declarations type both a server handler and a browser client. They live in two places with identical content:

// from the core package — alongside DashboardService
import type { DiskInfo, ObjectListResponse, MediaEntry } from '@adonis-agora/media/dashboard'

// from the standalone dashboard package — for a browser bundle that shouldn't pull in the server
import type { DiskInfo, ObjectListResponse, MediaEntry } from '@adonis-agora/media-dashboard/types'

The second subpath is the one to import from a frontend: it carries only these declarations, with no server code behind it.

TypeShape
Topology{ disks, hasUploads, actions }
DiskCapabilities{ presign, multipart, publicUrls, list }
DiskInfo / DiskListResponse{ name, default, capabilities } / { disks }
ObjectFolder / ObjectEntry{ name, prefix } / { key, name, sizeBytes, lastModified }
CursorParams{ after?, first? } — the input half of the cursor interface.
CursorPage<T> / CursorPageInfo{ items, nextCursor, prevCursor, hasNext, hasPrev } / the same without items.
ObjectListResponse{ folders, files, nextCursor, prevCursor, hasNext, hasPrev }
ObjectDetailResponse{ key, size, contentType?, lastModified?, url }
MediaEntryA MediaRecord projected for the console — ids, owner, collection, name/fileName, mimeType, sizeBytes, disk, path, conversions: string[] (names only), ISO timestamps.
CollectionListResponseCursorPage<MediaEntry>{ items, nextCursor, prevCursor, hasNext, hasPrev }
CollectionFilter{ collection?, ownerType?, ownerId?, prefix? } — every field optional, ANDed server-side.
CollectionSummary / CollectionsSummaryResponse{ key, count, sumSize } / { collections }
MediaVariant / MediaDetailResponse{ name, url } / { record, variants }
UploadInfo / UploadListResponse{ id, disk, key, offset, size, percent, parts, multipart, createdAt? } / { uploads }
UploadPart / UploadDetailResponse{ partNumber, etag } / { upload, parts }
ObjectInsight{ title, facts?, links?, note? } — with ObjectInsightFact ({ label, value }) and ObjectInsightLink ({ label, href }).
ObjectInsightsResponse{ insights }
CopyMoveBody{ disk, from, to, toDisk? }toDisk defaults to the source disk.
DeleteBody{ disk, keys }
FolderBody{ disk, prefix }
ConsoleSessionUserInfo / MeResponse{ id, name?, roles } / { authRequired: false } | { user }
LoginBody{ username, password }

Two projections are worth reading carefully. MediaEntry.conversions is a list of names, not the conversion objects — the console shows which derivatives exist, and mediaRecord(id) is where you get their URLs. And every timestamp crosses as an ISO-8601 string, because these shapes travel over JSON; nothing here is a Date.


Next steps

On this page