Skip to content

Files & renderer

Features for file storage, notification HTML rendering and PDF tables, plus the checks every upload goes through and the helper for creating files from code.

Status: ✅ Stable

What: S3-compatible file-storage backend. Targets AWS S3, Cloudflare R2, MinIO, Backblaze B2, anything that speaks the S3 API. Implements FileStorageProvider (write / writeStream / read / readStream / getSignedUrl / delete).

How it works: createS3Provider({ region, bucket, accessKeyId, secretAccessKey, endpoint?, forcePathStyle? }) builds the provider. forcePathStyle (or a custom endpoint) matters for MinIO + R2. Wire it into the files / file-foundation stack as your storage provider.

Env helper: createS3ProviderFromEnv() reads the S3_ prefix by default (S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, optional S3_ENDPOINT, S3_FORCE_PATH_STYLE). Pass another prefix (e.g. "MINIO_") when needed.

Example:

import {
createS3Provider,
createS3ProviderFromEnv,
} from "@cosmicdrift/kumiko-bundled-features/file-provider-s3/lib";
const fileProvider = createS3Provider({
region: "eu-central-1",
bucket: "kumiko-app-files",
accessKeyId: process.env["S3_ACCESS_KEY"]!,
secretAccessKey: process.env["S3_SECRET_KEY"]!,
});
// or: const fileProvider = createS3ProviderFromEnv(); // reads S3_* env
// Presigned GET URL (download). Upload path uses write/writeStream or the
// files feature upload routes, there is no uploadUrl helper.
const url = await fileProvider.getSignedUrl!(
`tenant-${tenantId}/${filename}`,
600,
{ contentDisposition: `attachment; filename="${filename}"` },
);

The multipart upload route POST /api/files validates a file in two steps before anything is written to storage.

First it checks the metadata. The size must fit the target field’s maxSize, or the maxUploadSize option of createFilesFeature() when the field sets none (10 MB by default). When the field declares accept, the extension must be in that list and the declared MIME type must fit the extension. Failures return 400 with file_too_large, invalid_file_type or mime_mismatch.

Then it reads the bytes. For jpg, jpeg, png, gif, webp, pdf, doc and docx the content must carry the signature that belongs to the extension. A renamed file, or an empty one, is rejected with 400 and an error starting with content_mismatch. Text formats such as txt, csv, json and svg have no signature and skip this step.

Windows browsers can declare a .csv file as application/vnd.ms-excel, because Explorer associates the extension with Excel. The route accepts that combination for fields that accept csv and stores the file as text/csv. If the bytes turn out to be a binary format, for example a real Excel file renamed to .csv, the upload fails with content_mismatch instead.

The same helpers are exported from @cosmicdrift/kumiko-framework/files for code that receives files by another path. sniffMimeType(bytes) returns the MIME type of a known binary signature (PNG, JPEG, GIF, WebP, PDF, OLE, ZIP) or null for text. Bytes alone cannot tell the Office formats apart: every OLE file comes back as application/msword, and every ZIP archive as the DOCX type. resolveUploadMimeType(fileName, declaredMimeType, bytes) applies the CSV alias and the binary check in one call.

A handler or job that produces bytes itself, such as a generated PDF or an export, stores them with createFileRefFromBytes(ctx, input) from @cosmicdrift/kumiko-framework/files. It runs the same pipeline as POST /api/files: identifier check, size and accept limits of the target field, content sniffing, the MIME allowlist, a tenant-prefixed storage key and the fileRef.created event. The tenant always comes from ctx.user.

The input is { fileName, mimeType, data, attachTo? }, where data is a Uint8Array and attachTo is { entityType, fieldName, entityId? }. The result is a WriteResult with { id, fileName, mimeType, size, storageKey }. A rejected file comes back as a 422 failure with the reason file_rejected and the upload error in details.error; a handler can return it unchanged. A file-provider feature must be mounted, otherwise ctx.services.files is missing and the call fails.

import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
// Inside a write handler: render, then keep the bytes as a regular FileRef.
const { pdfBytes } = await renderTablePdf(document);
const created = await createFileRefFromBytes(ctx, {
fileName: "audit-boiler-3.pdf",
mimeType: "application/pdf",
data: pdfBytes,
});
if (!created.isSuccess) return created;
return { isSuccess: true as const, data: { fileRefId: created.data.id } };

The FileRef is a normal one: GET /api/files/:id serves it with its stored content type, and other tenants get 404. Image variants from file-derivatives work on the result.

A job has no request user. Pass the system user instead, and run the job per tenant:

r.job(
"nightly-export",
{ trigger: { cron: "0 2 * * *" }, perTenant: true },
async (ctx) => {
const created = await createFileRefFromBytes(
{ ...ctx, user: ctx.systemUser },
{ fileName: "nightly-export.csv", mimeType: "text/csv", data: csvBytes },
);
// ...
},
);

With perTenant: true the job fans out once per tenant and ctx.systemUser is bound to that tenant. Without a tenant context, ctx.systemUser belongs to the system tenant, so the file would land there and no tenant could read it.

The FileRef records that user as its uploader. With attachTo, the default access guard (see Image and file fields) lets the uploader, privileged roles and everyone who can read the record read it.

The recipe pdf-audit-export shows the full path from tenant data to a stored PDF.

createFileField, createImageField, createFilesField and createImagesField declare upload fields. The singular factories hold one file, the plural ones a list. Options:

OptionApplies toEffect
acceptallAllowed extensions, checked on upload
maxSizeallSize limit, for example "5mb"; wins over the global default
maxCountfiles, imagesMaximum number of entries
variantsimage, imagesNamed derived versions, see file-derivatives
captureimage, images"environment" or "user": a phone opens the camera directly
import { createEntity, createImagesField } from "@cosmicdrift/kumiko-framework/engine";
const inspectionEntity = createEntity({
table: "read_inspections",
fields: {
photos: createImagesField({
accept: ["jpg", "png"],
maxCount: 6,
capture: "environment",
required: true,
}),
},
});

Edit forms render a generic multi-file widget for images and files fields. Users add files, see previews, remove entries and reorder them; the widget honours maxCount, accept and capture. required: true is allowed on these fields.

An upload from an update form carries the record’s entityId, so the FileRef is attached to the record. A create form uploads before the record exists; the create then attaches the user’s own uploads named in the new record’s file fields (single and plural), in the same transaction. Variants with a qr overlay depend on this, because the overlay resolver is looked up by the FileRef’s entity type.

Attached files follow the record. Anyone who can open the record can read its files: the default access guard runs the entity’s <entity>:detail query as the requesting user, so role access, row ownership and parent visibility apply. A technician who can open an inspection sees the photos other technicians attached to it. The uploader and the privilegedRoles (default Admin and SystemAdmin) can always read. Only the uploader and the privilegedRoles can delete. An entity without a detail query falls back to the uploader and the privilegedRoles.

Pass your own rule to createFilesFeature when files must be visible beyond the record’s readers:

import { createFilesFeature } from "@cosmicdrift/kumiko-bundled-features/files";
// Roles that may read files attached to any record in the tenant.
createFilesFeature({ privilegedRoles: ["Admin", "SystemAdmin", "Inspector"] });
// Or replace the rule entirely; this also replaces the record check.
createFilesFeature({
accessGuard: ({ fileRef, user }) =>
fileRef.insertedById === user.id ? "allow" : "deny",
});

The widget is built from the Input primitive for kind: "file" and kind: "image" plus buttons for moving entries up and down. Removing an entry uses the remove control of the Input primitive itself. Apps with their own primitive set (native, for example) therefore implement two things in that primitive: a remove control that calls onChange(null), and the optional entityId prop, sent as the entityId part of the upload so the FileRef is attached to the record. The web primitive builds that request body with buildFileUploadFormData from @cosmicdrift/kumiko-renderer/web. Its labels come from the kumiko.field.file-upload.* and kumiko.field.file-list.* i18n keys.

Status: ✅ Stable

What: Renders every notification mail. A mail is a stored template (kind: "mail", HTML body, subject in meta) that template-resolver resolves per tenant and locale. The renderer fills in the variables, places the body into an MJML mail layout and returns { subject, html, text } for channel-email.

How it works: Mount template-resolver, renderer-foundation and createRendererMailHtmlFeature() from @cosmicdrift/kumiko-renderer-mail-html. composeRendererStack() from @cosmicdrift/kumiko-dev-server mounts all three. r.notification(...) declares the mail itself: variables, the default text in template and the subject in meta. The boot seed writes that text into the template store, and a tenant can override it there without a deploy. inApp and push keep their own short text in templates. A notification without a mail template fails on the email channel.

Logo and footer come from the tenant branding config and the mail layout (kind: "mail-layout"), which the document-designer edits in the browser. Up to framework 0.362.0 this was renderer-simple with structured header / sections / footer objects; that renderer is gone.

Recipe: delivery-notifications declares a mail template, its variables and the inApp and push texts. Reference: renderer-mail-html.

Status: ✅ Stable

What: Draws a table document as a PDF in pure JavaScript with pdf-lib and the standard Helvetica fonts. No browser, no Puppeteer service, no native dependency. The same input gives byte-identical output. Server only.

How it works: renderTablePdf(doc, options?) returns { pdfBytes, pageCount }. The document has a title, an optional subtitle and meta lines, columns with a relative weight and an align, rows of strings, emptyText, footerText and a pageLabel template with {page} and {pages}. Long cells wrap and the header row repeats on every page. options takes format ("A4" or "Letter") and marginMm.

The renderer never reads the clock or the locale. The caller passes ready-formatted strings for dates and numbers. Helvetica uses the WinAnsi character set: umlauts, ß and € are drawn, any other character becomes ?.

Plugin: createRendererPdfFeature() registers the renderer extension pdf for kind: "document-pdf". Mount it next to renderer-foundation. payload.variables must match pdfTableDocumentSchema; anything else fails with RendererError and the code invalid_payload. A Puppeteer plugin that is also mounted keeps precedence for this kind, so use that one when you need HTML and CSS layouts.

Example:

import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
const { pdfBytes, pageCount } = await renderTablePdf(
{
title: "Inspection report",
meta: [{ label: "Asset", value: "Boiler 3" }],
columns: [
{ header: "Date" },
{ header: "Inspector", weight: 2 },
{ header: "Cost", align: "right" },
],
rows: [["03.02.2026", "Müller", "120,00 €"]],
footerText: "Example GmbH",
pageLabel: "Page {page} of {pages}",
},
{ format: "A4" },
);
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
features: [createRendererFoundationFeature(), createRendererPdfFeature(), /* ... */];

Recipe: pdf-audit-export renders tenant data to a PDF and keeps it as a FileRef with createFileRefFromBytes.