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.
file-provider-s3
Section titled “file-provider-s3”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}"` },);Upload checks
Section titled “Upload checks”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.
Writing files from code
Section titled “Writing files from code”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.
Image and file fields
Section titled “Image and file fields”createFileField, createImageField, createFilesField and createImagesField declare
upload fields. The singular factories hold one file, the plural ones a list. Options:
| Option | Applies to | Effect |
|---|---|---|
accept | all | Allowed extensions, checked on upload |
maxSize | all | Size limit, for example "5mb"; wins over the global default |
maxCount | files, images | Maximum number of entries |
variants | image, images | Named derived versions, see file-derivatives |
capture | image, 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.
renderer-mail-html
Section titled “renderer-mail-html”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.
renderer-pdf
Section titled “renderer-pdf”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.
See also
Section titled “See also”- Bundled-features overview
- Notifications,
delivery+ channels - Recipe
delivery-notifications