Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Writing a storage provider

Media plugin @orthacms/media-server@orthacms/media-admin

One factory function, one call to the contract suite, and the rules it holds you to.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A new storage backend is one factory returning a StorageProvider, plus one call to the contract suite.

The port

From @orthacms/media-domain, a package with no dependencies (@orthacms/media-server re-exports the same names):

import type { Readable } from 'node:stream';

interface StorageProvider {
    readonly id: string;                        // stored on every asset row
    readonly capabilities: StorageCapabilities;
    put(object: PutObject): Promise<StoredObject>;
    get(storageKey: string): Promise<Readable>;
    remove(storageKey: string): Promise<void>;
    directUrl?(storageKey: string, options: DirectUrlOptions): Promise<string>;
    verify?(): Promise<void>;                   // run at boot; a throw refuses the start
}

interface StorageCapabilities {
    directUrl: boolean;            // can sign a short-lived URL with pinned headers
    contentTypeMetadata: boolean;  // can store a content type with the bytes
    streamingPut: boolean;         // streams the body rather than buffering it
}

interface PutObject {
    workspaceId: string;
    assetId: string;
    fileName: string;
    contentType: string;
    body: Readable;
    isVariant?: boolean;           // a derivative (thumb, preview)
}

interface StoredObject {
    storageKey: string;            // you choose it
    size: number;                  // bytes actually written
    checksum: string;              // hex sha256 of what was written
}

interface DirectUrlOptions {
    disposition: 'inline' | 'attachment';
    fileName: string;
    contentType: string;
    expiresInSeconds: number;
}

Plus ObjectNotFoundError(storageKey), which get must reject with for a missing key.

The contract

  1. put returns the true size and sha256 of what it wrote.
  2. put is all-or-nothing. A body that fails mid-stream leaves nothing behind: the key was never returned, so nobody could reclaim it.
  3. Distinct keys for two assets with the same file name, and for a derivative vs. an original of the same name.
  4. get rejects with ObjectNotFoundError for a missing or removed key, before the stream opens (after that, the route cannot turn a 200 into a 404).
  5. remove is idempotent, a no-op for a key that never existed.
  6. Async all the way: reject, never throw synchronously.
  7. directUrl() exists exactly when capabilities.directUrl is true, and only if the URL pins Content-Disposition and Content-Type.

A complete provider

For a hypothetical HTTP object store with PUT/GET/DELETE per key and a bearer token:

// packages/media-provider-http-store/src/http-store-provider.ts
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import {
    ObjectNotFoundError,
    type PutObject,
    type StorageProvider,
    type StoredObject
} from '@orthacms/media-domain';

export interface HttpStoreConfig {
    baseUrl: string;                    // e.g. https://objects.acme.example/cms
    token: string;
    keyPrefix?: string;
    fetch?: typeof globalThis.fetch;    // test seam
}

// One safe path segment from an uploader-supplied file name.
const sanitize = (name: string): string =>
    name.replace(/[^\w.-]+/g, '_').replace(/^\.+/, '') || 'file';

export function createHttpStoreProvider(config: HttpStoreConfig): StorageProvider {
    const doFetch = config.fetch ?? globalThis.fetch;
    const base = config.baseUrl.replace(/\/+$/, '');
    const prefix = config.keyPrefix?.replace(/^\/+|\/+$/g, '');
    const auth = { authorization: `Bearer ${config.token}` };
    const urlFor = (key: string) =>
        `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;

    const keyFor = (object: PutObject): string =>
        [
            prefix,
            object.workspaceId,
            object.assetId,
            object.isVariant ? 'variants' : undefined, // derivatives never collide with originals
            sanitize(object.fileName)
        ]
            .filter(Boolean)
            .join('/');

    return {
        id: 'http-store',
        capabilities: { directUrl: false, contentTypeMetadata: true, streamingPut: false },

        async put(object: PutObject): Promise<StoredObject> {
            const storageKey = keyFor(object);

            // Buffer and meter first. A body that fails mid-stream rejects
            // here, before anything is written: all-or-nothing by construction.
            const hash = createHash('sha256');
            const chunks: Buffer[] = [];
            for await (const chunk of object.body) {
                const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
                hash.update(buffer);
                chunks.push(buffer);
            }
            const bytes = Buffer.concat(chunks);

            const response = await doFetch(urlFor(storageKey), {
                method: 'PUT',
                headers: { ...auth, 'content-type': object.contentType },
                body: bytes
            });
            if (!response.ok) {
                // Remove any partial write: the key is never returned to a caller.
                await doFetch(urlFor(storageKey), { method: 'DELETE', headers: auth }).catch(
                    () => undefined
                );
                throw new Error(`http-store refused ${storageKey}: HTTP ${response.status}`);
            }
            return { storageKey, size: bytes.length, checksum: hash.digest('hex') };
        },

        async get(storageKey: string): Promise<Readable> {
            const response = await doFetch(urlFor(storageKey), { headers: auth });
            if (response.status === 404) {
                throw new ObjectNotFoundError(storageKey); // before any byte flows
            }
            if (!response.ok || !response.body) {
                // Not "not found": an outage must not be dressed as a 404.
                throw new Error(`http-store could not read ${storageKey}: HTTP ${response.status}`);
            }
            return Readable.fromWeb(response.body as unknown as WebReadableStream);
        },

        async remove(storageKey: string): Promise<void> {
            const response = await doFetch(urlFor(storageKey), { method: 'DELETE', headers: auth });
            if (!response.ok && response.status !== 404) {
                throw new Error(`http-store could not remove ${storageKey}: HTTP ${response.status}`);
            }
        },

        async verify(): Promise<void> {
            const response = await doFetch(`${base}/`, { method: 'HEAD', headers: auth });
            if (!response.ok) {
                throw new Error(`http-store at ${base} refused the token: HTTP ${response.status}`);
            }
        }
    };
}

To stream instead of buffer, pipe object.body through a metering transform into the upload, declare streamingPut: true, and delete the object in the catch when anything fails.

A capability is a claim, not a wish

directUrl: true without a directUrl() method fails at boot. Declaring it for a URL that cannot pin Content-Disposition and Content-Type is worse: an uploaded .html would render on your storage host’s origin. Vercel Blob declares false for that reason.

The contract suite

npm install --save-dev @orthacms/media-provider-testkit

describeStorageProvider(name, harness) registers Jest-style describe/it cases. The harness is { create, cleanup?, storedKeys? }:

  • create runs per case, so each case gets a fresh provider.
  • storedKeys turns “the failed put rejected” into “and left nothing behind”. Provide it.
// packages/media-provider-http-store/src/http-store-provider.spec.ts
import { describeStorageProvider } from '@orthacms/media-provider-testkit';
import { createHttpStoreProvider } from './http-store-provider';

// An in-memory stand-in for the HTTP object store.
function fakeStore() {
    const objects = new Map<string, Uint8Array>();
    const fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
        const key = decodeURIComponent(new URL(String(input)).pathname.replace(/^\/cms\/?/, ''));
        switch (init.method ?? 'GET') {
            case 'PUT':
                objects.set(key, new Uint8Array(init.body as Uint8Array));
                return new Response(null, { status: 201 });
            case 'DELETE':
                return new Response(null, { status: objects.delete(key) ? 204 : 404 });
            case 'HEAD':
                return new Response(null, { status: 200 });
            default: {
                const bytes = objects.get(key);
                return bytes ? new Response(bytes) : new Response(null, { status: 404 });
            }
        }
    }) as typeof globalThis.fetch;
    return { fetch, objects };
}

let store = fakeStore();

describeStorageProvider('http-store', {
    create: () => {
        store = fakeStore();
        return createHttpStoreProvider({
            baseUrl: 'https://objects.test/cms',
            token: 'test-token',
            fetch: store.fetch
        });
    },
    storedKeys: () => [...store.objects.keys()]
});

What it checks: a non-empty id; complete capabilities; directUrl() present exactly when claimed; true size and sha256; round-trip through get; distinct keys; derivative vs. original; mid-stream failure leaves nothing; ObjectNotFoundError for never-written and removed keys; idempotent remove; no synchronous throws.

Register it

// apps/server/src/plugins.ts
import { createHttpStoreProvider } from '@acme/media-provider-http-store';

MediaServerPlugin({
    provider: createHttpStoreProvider({
        baseUrl: process.env.MEDIA_HTTP_STORE_URL!,
        token: process.env.MEDIA_HTTP_STORE_TOKEN!
    }),
    config: config.plugins.media
});

No registry, no name to declare elsewhere. Then:

  • Boot: verify() runs; a throw refuses the start.
  • Upload a file (curl example) and check the object exists at <workspaceId>/<assetId>/<file-name>.
  • A database with rows from another provider id refuses to boot. Changing id later strands every existing asset.

Read the in-memory provider first

It is a known-good implementation, short enough to read in one sitting.