[data-reveal]{opacity:1!important;transform:none!important}
Interface reference
Interface reference

Media interface

Asset fields, folder binding lifecycle, declared content and permission contracts for DMS consumers.

@antelopejs/interface-dms-media exports the asset field and folder binding declarations, and the functions that ship library folders and files as code. Load the Media runtime for the picker, uploads and provisioning. HTTP routes and delivery URLs remain in the runtime guide.

Declare a field

This field stores up to four asset IDs and registers a tenant-local upload folder. Place it in a DMS form. The permission IDs are application-defined and must be granted by the project's role configuration.

import { AssetType } from "@antelopejs/interface-dms-media";

export const articleImages = new AssetType({
  multiple: true,
  max: 4,
  mimetypes: ["image/*"],
  binding: {
    id: "articles.images",
    folderName: "Article images",
    permissionMapping: {
      read: ["articles.page"],
      write: ["articles.page.table.edit"],
    },
  },
});

Selecting two images produces a value such as ["asset-a", "asset-b"], not two URLs. An empty selection is []. A fifth ID fails the field's shape validation, but an unknown nonempty ID does not: the consuming write path still needs to check asset existence, tenant ownership and type. See the picker example for the UI.

AssetType

Contract
import type { AssetBindingConfig } from "@antelopejs/interface-dms-media";

interface AssetTypeOptions extends Record<string, unknown> {
  multiple?: boolean;
  max?: number;
  mimetypes?: string[];
  binding?: AssetBindingConfig;
}

interface AssetPickerComponentOptions {
  multiple?: boolean;
  max?: number;
  mimetypes?: string[];
  bindingId?: string;
}

new AssetType(options?: AssetTypeOptions) returns a DMS DataType. With default options, validation accepts one nonempty asset-ID string. multiple: true accepts an array of nonempty strings, including an empty array; max caps its length only in multiple mode. getValidation() returns a Zod schema: it validates that shape, not asset existence, ownership or MIME type.

mimetypes limits the picker to types such as image/* or application/pdf. Bound-folder uploads also enforce the binding's MIME types server-side, alongside the runtime upload policy. Do not treat the form schema or client-side filtering as an authorization check.

Constructing a field with binding registers that binding and fills its field metadata from multiple, max and mimetypes. The picker receives AssetPickerComponentOptions with the binding's ID, not its ACL. The exported constants are ASSET_DATA_TYPE_ID = "asset" and ASSET_PICKER_COMPONENT = "dms-media-asset-picker"; importing the field module registers the data type.

RegisterAssetBinding and UnregisterAssetBinding

Contract
import type { ControllerClass } from "@antelopejs/interface-api";

type AclRight = "read" | "write" | "manage";
type AclSubjectKind = "permission" | "role";
interface AclSubject { kind: AclSubjectKind; id: string; }
interface AclEntry { subject: AclSubject; rights: AclRight[]; }

interface AssetPermissionMapping {
  read?: string[];
  write?: string[];
}

interface AssetBindingField {
  multiple?: boolean;
  max?: number;
  mimetypes?: string[];
}

interface AssetBindingConfig {
  id: string;
  folderName?: string;
  permissionMapping?: AssetPermissionMapping;
  permissionsFromPage?: ControllerClass;
  acl?: AclEntry[];
  subfolders?: boolean;
  field?: AssetBindingField;
}

declare function RegisterAssetBinding(config: AssetBindingConfig): void;
declare function UnregisterAssetBinding(id: string): void;

All these types and functions are available from the package root. The exported internal proxy namespace is not a consumer API.

Use a stable, namespaced ID such as articles.cover. Registrations are keyed by ID, so reusing an ID replaces its declaration rather than creating an independent binding. Registration returns synchronously; it does not await folder creation. The calling module owns the registration: unloading it releases the declaration, and reloading the implementation replays active registrations.

The runtime provisions each tenant separately, under a private Content root. A new folder uses folderName ?? id; an existing bound folder retains its name and gets the current ACL. UnregisterAssetBinding removes the declaration but leaves provisioned folders and files in place.

A bound folder is locked: it cannot be renamed, moved, deleted or re-permissioned, and by default it holds no subfolders. subfolders: true lets it hold them, for a layout per record such as one folder per project under the field's folder. Owners then create subfolders in the library, and backend code declares them with EnsureMediaFolder({ parent: { binding } }); the bound folder itself stays locked. A subfolder inherits the bound folder's ACL unless it has its own, and accepts only the types the field accepts, like the bound folder. The field's picker opens on the bound folder and browses its subfolders; its upload button still targets the bound folder. Neither function returns an upload result or a provisioning error; provisioning/storage failures occur when runtime operations ensure the tenant's folders.

Permission precedence and tenant access

  1. An explicit acl, including [], replaces all derived entries.
  2. Otherwise, permissionMapping.read and .write take precedence independently over the page-derived permission. An explicit empty array disables fallback for that right.
  3. Missing mappings fall back to the effective permission of permissionsFromPage, resolved lazily at provisioning time so a field can reference its enclosing page class.

ACL rights imply weaker rights: manage ⇒ write ⇒ read. A role must explicitly grant the page permission; memberAccess or publicAccess alone does not grant a folder right. The runtime's Media administration pages remain owner-only. Members reach accessible files through asset fields on project pages, not by receiving a Media page permission.

Owners hold every folder right. Uploading and changing files require folder write; folder structure changes require manage. File and folder visibility changes stay owner-only. A writable linked folder lets a member modify its files regardless of who uploaded them. The default root ACL grants no member access; configure rootAcl deliberately because its grants can open the rest of the library.

EnsureMediaFolder and ImportMediaAsset

A project ships media content (templates, procedures, demo pictures) from its backend code with these two functions. The Media runtime creates the content itself: it validates it with the dashboard's rules, writes the bytes to its configured file storage, computes image dimensions and records the creation in the library activity. Never write the runtime's tables yourself.

Contract
import type { AclEntry } from "@antelopejs/interface-dms-media";

type MediaFolderVisibility = "private" | "public";

interface MediaFolderDeclaration {
  key: string;
  name: string;
  parent?: string | MediaFolderTarget;
  visibility?: MediaFolderVisibility;
  acl?: AclEntry[];
  tenantId: string;
}

interface DeclaredFolderTarget { key: string; }
interface BoundFolderTarget { binding: string; }
type MediaFolderTarget = DeclaredFolderTarget | BoundFolderTarget;

interface MediaBytesSource { bytes: Uint8Array; }
interface MediaFileSource { filePath: string; }
interface MediaLazySource { load: () => Uint8Array | Promise<Uint8Array>; }
type MediaAssetSource = MediaBytesSource | MediaFileSource | MediaLazySource;

interface MediaAssetDeclaration {
  key: string;
  folder: MediaFolderTarget;
  name: string;
  mimetype: string;
  source: MediaAssetSource;
  version?: string;
  alt?: string;
  tenantId: string;
}

type MediaDeclarationStatus = "created" | "updated" | "unchanged" | "preserved" | "deleted";

interface MediaDeclarationResult {
  status: MediaDeclarationStatus;
  id?: string;
}

declare function EnsureMediaFolder(declaration: MediaFolderDeclaration): Promise<MediaDeclarationResult>;
declare function ImportMediaAsset(declaration: MediaAssetDeclaration): Promise<MediaDeclarationResult>;
  • key is a stable, namespaced id you choose, such as helio.procedures. Declarations are keyed by tenant, kind and key: calling them on every boot or hot reload creates each folder and file once.
  • A folder goes at the library root, under the declared folder named by parent (its key, or { key }), or under the folder bound to an asset field ({ binding }) when that binding sets subfolders: true. visibility defaults to private; without acl the folder inherits its parent's ACL (the rootAcl at the root).
  • A file goes into a declared folder ({ key }) or into the folder bound to an asset field ({ binding }, the id given to AssetType or RegisterAssetBinding, which the runtime provisions first). source is the content in memory (bytes), a path on the backend's file system (filePath) or a function producing it (load). name is the file name shown in the library, extension included.
  • The source is read only when a decision needs the bytes: never for a file a member changed or deleted, and not for an unchanged file whose version matches. version is an opaque string you change whenever the bytes change, such as a hash of what they are generated from: while it equals the version of the key's last declaration, the runtime reuses the content hash it recorded then instead of reading source. Pair it with load to skip generating content on every boot or hot reload. A new version whose bytes turn out identical is unchanged and remembered, so the next call does not read them again. Without version, every call reads the source to compare it.
  • tenantId is required in TypeScript and at runtime. Omission, an empty string or whitespace-only input rejects before database writes or source reads. There is no default fallback; passing DEFAULT_TENANT_ID explicitly remains valid. Existing callers of these new APIs must add their trusted tenant id; older media APIs are unchanged.
  • id in the result is the library id of the folder or file, to store in an AssetType value for example.

The refresh rule

For each key, the runtime remembers the last declaration it applied and a hash of the row's editable state right after applying it: a folder's name, parent, visibility and ACL; a file's name, folder, alt text, visibility and stored content. On each call:

  1. No row was ever declared for the key: the folder or file is created (created).
  2. The row was deleted since, by a member or with its folder: it is not created again (deleted). A file whose declared folder was deleted before it was ever imported is deleted too.
  3. The row's current state no longer hashes to what the last declaration left: someone changed it, so it is left as is, whatever the new declaration says (preserved).
  4. The declaration equals the last one applied: nothing is written (unchanged). A file's content counts as equal when its bytes hash the same, or when its version equals the last one without the bytes being read.
  5. Otherwise the row still holds exactly what was declared, and is refreshed to the new declaration (updated): a new name, place, visibility, ACL or alt text, or new bytes, which replace the stored file and its generated sizes the way an image edit does.

Refreshes compare the observed editable state in the database write itself. A member edit that wins that race is preserved, or deleted if the row was removed. File metadata and replacement bytes are published together; a rejected replacement does not become the live file. An edit made after a successful refresh remains a member edit on the next declaration, rather than becoming the declaration's baseline.

Only creations are recorded in the activity, attributed to System, as folder.create and asset.upload; refreshes are not.

The comparison is of state, not edit history: restoring exactly the last applied state makes the row eligible for refresh again. Removing a declaration from code does not delete its row: retire it (below). Use one declaration writer per tenant and key: the queue is process-local, and creation, storage, activity and declaration bookkeeping are not a single transaction. Folder moves and descendant path rewrites are also separate writes; this API is not a transaction across a whole folder tree.

Retiring content the code no longer declares

Contract
type MediaContentKind = "folder" | "asset";

interface MediaDeclarationRetirement {
  kind: MediaContentKind;
  key: string;
  tenantId: string;
}

interface MediaDeclarationPrune {
  prefix: string;
  keep: string[];
  tenantId: string;
}

type MediaRetirementStatus = "removed" | "preserved" | "deleted" | "unknown";

interface MediaRetirementResult {
  kind: MediaContentKind;
  key: string;
  status: MediaRetirementStatus;
  id?: string;
}

declare function RetireMediaDeclaration(retirement: MediaDeclarationRetirement): Promise<MediaRetirementResult>;
declare function PruneMediaDeclarations(prune: MediaDeclarationPrune): Promise<MediaRetirementResult[]>;

RetireMediaDeclaration takes back one declaration, by the kind and key it was made with. PruneMediaDeclarations retires every declaration whose key starts with prefix and is not listed in keep (keys of folders and files alike): call it after the declarations of the current code, with the keys they used, and whatever an earlier version shipped and this one dropped is taken back. It retires files first, then folders from the deepest, and returns one result per retired declaration. The rule follows the refresh rule:

  1. Nothing is declared under the kind and key, never or no longer: nothing happens (unknown).
  2. The row was deleted since: deleted.
  3. The row's state no longer hashes to what the last declaration left, or the folder holds anything (members' files and folders, or declared content not retired yet): it stays as it is (preserved).
  4. Otherwise the row still holds exactly what was declared: it is deleted like a member's delete, its stored files with it, and recorded as asset.delete or folder.delete by System (removed).

In every case but unknown the declaration is forgotten: a preserved folder or file now belongs to the members, and declaring the key again creates new content. Retiring is never a cascade: a folder is removed only once empty, so keep in keep the folders the code still imports into. The delete is conditional on the observed state in the database write, so a member edit racing it wins. Only one module should own a prefix; prefix is required and nonblank.

Validation and errors

Declarations follow the dashboard's rules and reject with an error naming the kind, the key and the reason, such as Media folder 'helio.procedures': a folder named 'Procedures' already exists there:

  • folder names refuse / \ < > : " | ? * and control characters, stay within 255 characters and must be unique among their siblings, case and Unicode form aside;
  • a parent or { key } folder must have been declared first;
  • a folder cannot name itself as its parent or move under one of its descendants; these declarations reject without changing the hierarchy;
  • a { binding } folder must have been registered, and a { binding } parent must allow subfolders;
  • a file must be of an allowed type (allowedMimetypes, never HTML, XHTML or PHP), of a type the bound field accepts, within the storage quota and the 500 MB upload limit, and a decodable image when its mimetype says so; the stored mimetype is the decoded format's, as for an upload;
  • alt text stays within 255 characters.

Calling them at startup

A call made before the runtime is loaded is queued by the interface, and the runtime waits for the explicitly selected tenant's tables (up to 5 minutes) and for the file storage to answer (up to 2 minutes; @antelopejs/file-storage-local receives files on the API server, which may not listen yet). This is technical readiness, not proof that SaaS provisioning is committed. Do not await them in construct(): the database only becomes ready once every module is constructed. Declare a parent before its children and a folder before its files, awaiting each call. Reserve start() for registration or explicitly scoped global work; use the application lifecycle below for new SaaS tenants.

Tenant scope and trusted backend context

tenantId selects the existing DMS tenant schema instance for the folders, files and declaration registry. Parent keys and bindings resolve within that tenant, and the same declaration key can be used independently in different tenants. Both functions require a nonblank explicit tenant; neither infers it from an HTTP session or a SaaS workspace context. Surrounding whitespace is trimmed.

These are trusted backend APIs, not authorization endpoints. They need the media implementation, the selected tenant's database instance and reachable file storage; they do not require a signed-in user, create a workspace, check payment status or decide when workspace provisioning is committed. Storage paths include the tenant id, but the storage backend and upload policy remain module configuration. Events are attributed to System.

start() runs for the process, not once per tenant. The routine below receives its trusted tenant from the application; it does not initialize every existing or future workspace. A required tenant id proves neither authorization nor completed provisioning.

import {
  EnsureMediaFolder,
  ImportMediaAsset,
  type MediaDeclarationResult,
} from "@antelopejs/interface-dms-media";

export async function declareProcedures(tenantId: string, pdf: Uint8Array): Promise<MediaDeclarationResult[]> {
  const root = await EnsureMediaFolder({ tenantId, key: "helio.procedures", name: "Procedures" });
  const safety = await EnsureMediaFolder({
    tenantId,
    key: "helio.procedures.safety",
    name: "Safety",
    parent: "helio.procedures",
    acl: [
      { subject: { kind: "permission", id: "projects.page" }, rights: ["read"] },
    ],
  });
  const file = await ImportMediaAsset({
    tenantId,
    key: "helio.procedures.safety.roof",
    folder: { key: "helio.procedures.safety" },
    name: "Roof safety checklist.pdf",
    mimetype: "application/pdf",
    source: { bytes: pdf },
  });
  return [root, safety, file];
}

New SaaS tenants: application-owned durable lifecycle

For resources needed after payment, register an application consumer through RegisterWorkspaceLifecycleConsumer from @antelopejs/interface-dms-saas/workspace-lifecycle, with transitions: ["created"]. Register it synchronously in construct() before startup reconciliation begins: module start() functions may run concurrently. Do not await media declarations in construct(); they run later inside consume. The existing SaaS created transition follows preparation and payment and is delivered at least once. Media neither depends on SaaS at runtime nor registers this consumer itself.

The following is an integration skeleton, not a runnable persistence implementation. Replace the fail-closed body with the application's durable operation handling before enabling it:

import { RegisterWorkspaceLifecycleConsumer } from "@antelopejs/interface-dms-saas/workspace-lifecycle";

export function construct(): void {
  RegisterWorkspaceLifecycleConsumer({
    name: "app.media-content",
    transitions: ["created"],
    async consume({ tenantId, operationId }) {
      // Deduplicate operationId in durable application storage, scoped to this
      // consumer and tenantId; replay the stored receipt for a completed operation.
      // Otherwise call declareProcedures(tenantId, pdf), inspecting its results.
      // Durably link the resulting resources to the operation before returning
      // { receiptId: persistedReceiptId }. Recover incomplete attempts on retry.
      // Concurrent deliveries need application-level serialization/uniqueness.
      throw new Error(`Implement durable receipt handling for ${tenantId}/${operationId}`);
    },
  });
}

The application supplies the content and durable storage, handles preserved and deleted according to its business contract, and returns { receiptId, ... } only after resource links and completion are durable. Retain the declaration results needed to link resources; deleted has no resource id. Do not acknowledge errors, or mint a receipt before linking resources. Idempotent declarations do not make every effect exactly-once: database, storage, activity and declaration records are separate writes, and media's key queue is process-local. Recovery and coordination across workers remain application responsibilities.

TENANT_BEING_PROVISIONED is different: it runs before payment, is awaited in series, can reject tenant creation, and has neither automatic hook replay nor automatic compensation for consumer effects. It is not a substitute for durable created. These recommendations do not change the application's provisioning policy or move existing hooks.

Existing tenants: deployment refresh and backfill

A new-tenant created consumer does not update all existing tenants when content changes at deployment. Run a separately controlled refresh/backfill over the application's trusted tenant inventory. For each tenant, check IsWorkspaceProvisioningCommitted(tenantId) through the SaaS interface before declaring content. If the result is false, skip it; if inventory or commitment lookup fails, stop or retry without writing (fail closed). Never replace a missing tenant with DEFAULT_TENANT_ID. Coordinate this writer with lifecycle deliveries, use stable keys, and retain progress/errors for retries. An explicitly selected default tenant remains suitable for intentional single-tenant/global content, not as an implicit SaaS fallback.