AdminKit
AdminKit is the administration engine we use for small, content-driven websites built with Hugo and deployed on Cloudflare Pages.
It turns a Zod schema into three things that must agree:
- the model edited in the browser;
- the YAML front matter stored in the repository;
- the validation performed by the server.
There is no second list of fields hidden in the UI, and no second serializer hidden in a Worker. A field is declared once. The rest of the system reads that declaration.
The package also contains the React administration shell, a media library for R2, Cloudflare Pages Functions, and the persistence primitives used by the D1 publication path.
Transfer email — 2.2.45
Transfer supports independent owner notifications and automatic recipient emails
through Resend, with optional recipient address, explicit opt-in, durable retries,
send history and confirmed resends. Transfer settings include the editable owner
email and owner notifications for each newly requested download. Sending stays disabled until configured per
site. Install migration 0012_delivery_email.sql and configure the server-only
email variables described in the integration guide.
Existing transfers are not emailed automatically. The illustrated Transfer guide
includes the email workflow and explains provider acceptance versus delivery.
The important property
An admin panel should not damage a file merely because it does not understand all of it.
AdminKit preserves the Markdown body, unknown front-matter keys, and their comments. An omitted managed field means “leave it alone”, not “delete it”. Before overwriting an existing file, checkFidelity() can prove that reading and writing it again is byte-for-byte stable.
If the codec cannot reproduce a file, it says so. Refusing a save is better than producing plausible-looking YAML with missing data.
Install
npm install @cardinali-studio/adminkit zod
The core codec needs Zod 4. The React application and form exports also use the optional peer dependencies listed in package.json; install the ones required by the entry points you use.
The package is ESM-only.
A complete codec example
Each field has two pieces of metadata:
uidescribes how a human edits it;fmdescribes how it lives in the file.
import * as z from "zod";
import {
checkFidelity,
deserializeItem,
fm,
serializeItem,
ui,
yamlStr,
} from "@cardinali-studio/adminkit";
const Article = {
title: z.string().min(1).max(120)
.register(ui, {
label: "Title",
widget: "text",
group: "content",
})
.register(fm, {
keys: ["title"],
encode: (value) => [`title: ${yamlStr(value)}`],
decode: (raw) => String(raw.title ?? ""),
}),
published: z.boolean()
.register(ui, {
label: "Published",
widget: "boolean",
group: "visibility",
})
.register(fm, {
keys: ["visible"],
encode: (value) => [`visible: ${value === true}`],
decode: (raw) => raw.visible === true,
}),
};
const markdown = `---
# Kept with the field.
title: "A small page"
visible: false
# AdminKit does not own this key, so it stays untouched.
build-note: "keep me"
---
The Markdown body stays untouched too.
`;
if (!checkFidelity(markdown, Article).ok) {
throw new Error("This file cannot be edited without loss");
}
const article = deserializeItem(markdown, Article);
const updated = serializeItem(
{ ...article, published: true },
{ shape: Article, oldMd: markdown },
);
Notice that the model calls the boolean published while the file calls it visible. That conversion belongs to the schema. Hugo templates, React components, and API handlers should not each invent their own version of it.
Deleting and retiring fields
Absence is not deletion. To clear a value, pass the empty value understood by its encoder and return no YAML lines.
When a key is obsolete, retired(schema, keys) keeps ownership of the old key while emitting nothing. Existing files then lose that key on their next intentional save, without maintaining a separate migration list.
Mounting the admin application
The application is deliberately mounted as one unit:
import { mountAdmin, type SiteAdminConfig } from "@cardinali-studio/adminkit/app";
import "@cardinali-studio/adminkit/theme.css";
import "./site-theme.css";
const site: SiteAdminConfig = {
// Site identity, page types, schemas, enabled modules and persistence mode.
// TypeScript checks the complete contract here.
};
mountAdmin({
site,
root: document.querySelector("#root"),
storagePrefix: "example-admin",
});
Import the site's theme after AdminKit's theme. This lets a site replace the CSS custom properties without increasing selector specificity.
storagePrefix is part of the site's browser data. Changing it on a live installation silently resets saved media preferences, so migrations should keep the old value.
The shell currently speaks Italian. Content can be monolingual or multilingual; the language of the administration interface is a separate concern.
Cloudflare Pages Functions
Cloudflare routes Functions by file, while an npm package cannot create files in a consuming project's functions/ directory. A site therefore keeps two small adapters.
The API catch-all:
// functions/admin/api/[[path]].js
import { handleAdminApi } from "@cardinali-studio/adminkit/functions";
import { serverConfig } from "../../../src/admin/site-config.js";
export function onRequest(context) {
return handleAdminApi(context, serverConfig);
}
And the access middleware:
// functions/admin/_middleware.js
export { adminAccess as onRequest } from "@cardinali-studio/adminkit/functions";
Site-specific endpoints may live beside these files. More specific Cloudflare routes take precedence over the catch-all.
The server configuration is site-owned. It supplies schemas, repository details, enabled modules, storage bindings and, in D1 mode, the complete document validator. Missing optional modules fail closed.
Persistence and publication
The original persistence path writes Markdown through GitHub and lets Hugo build it. The newer path stores immutable revisions in D1 and publishes an exact release snapshot.
The D1 modules are intentionally lower-level than the admin shell. They provide:
- deterministic request checksums and idempotent saves;
- compare-and-swap protection for concurrent drafts;
- immutable, checksum-covered release exports;
- a single deployment lane for code and content;
- copy-first media moves and delete journals for R2;
- versioned site-access secrets;
- pure release materialization, with filesystem effects left to CI.
They do not decide what a site's document means. Validation and materialization remain site-owned.
Entry points
| Import | Purpose |
|---|---|
@cardinali-studio/adminkit | Schema registries, front-matter codec, API client, i18n, persistence and release primitives |
@cardinali-studio/adminkit/app | Complete React administration shell |
@cardinali-studio/adminkit/form | Schema form and media components |
@cardinali-studio/adminkit/functions | Cloudflare Pages API router and access middleware |
@cardinali-studio/adminkit/materializer | Pure release-to-files materialization |
@cardinali-studio/adminkit/preview | Local preview component |
@cardinali-studio/adminkit/testing | Content-stability checks for consuming sites |
@cardinali-studio/adminkit/theme.css | Default design tokens |
What this is not
AdminKit is not a generic headless CMS, a form builder, or a promise to support every YAML construct.
It is opinionated about the useful narrow case: Hugo content, React 19, Zod 4, Cloudflare Pages, R2 and optionally D1. The front-matter emitter supports the structures used by that system and throws on values it cannot represent safely.
This narrowness is intentional. A smaller system with explicit failure modes is easier to trust around content that people have edited by hand.
Delivery ZIP compatibility (2.2.38)
New delivery archives use STORE members with their CRC and sizes in each local header, without data descriptors. This prevents streaming extractors from mistaking descriptor-shaped bytes inside a photograph for the end of the file. CRC preparation reads bounded 4 MiB slices and remains pausable and cancellable; source changes fail closed. ZIP64 sizes and offsets are promoted independently.
Persistent checkpoints record store-header-v1. Checkpoints from earlier releases continue with store-descriptor-v1, preserving confirmed multipart bytes and derivative retries. The update affects newly generated archives; it does not rewrite existing stored ZIPs. Consumers must update the dependency, rebuild and redeploy.
The regression includes 208 synthetic members with the collision at member 53, streaming and indexed readers, ZIP64 offsets, and byte-identical resume against fixtures from 2.2.37.
Permanent delivery storage cleanup
Delivery cleanup permanently deletes all versions and delete markers in the removed package's exact storage prefix, and aborts unfinished multipart uploads. Successful deletion requires a subsequent empty listing; a failed or partial sweep keeps the database work for retry. No bucket-wide cleanup or lifecycle is installed.
Apply dist/functions/delivery-public/migrations/0010_delivery_storage_cleanup.sql
to the consumer's delivery D1 before deploying this code. The new
delivery_storage_cleanup table has no foreign keys: its scoped tombstones survive
removal of a delivery, so a PUT or multipart completion already in flight cannot
become permanently untracked. The housekeeping caller must continue invoking
purgeDue independently of visits to the admin or public pages. Scopes are checked
again after 15 minutes in the first hour, then daily, subject to the caller's request
budget and backlog. Tombstones are deliberately retained indefinitely. last_error
records failed sweeps; last_checked_at records successful empty verification.
Expired deliveries cannot request new upload signatures or start/finalize new packages. An uploading package retains its one-hour metadata grace period; its durable scope also catches requests finishing after that grace period. A late write can occupy space temporarily until the next successful sweep.
The opt-in live regression scripts/verify-delivery-storage.mjs uses a new random
prefix and an in-memory D1 database. It creates versions, a delete marker, an
unfinished multipart upload, and a PUT authorized before deletion. It checks zero
remaining versions/bytes/uploads after both deletion and expiration, and compares
all pre-existing version IDs before and after. Every mutation outside the new
prefix is refused. Configuration and secret-file paths are arguments; credentials
are never printed. This test does not deploy a Worker or change lifecycle rules.
Do not deploy this cleanup against a populated production bucket when existing objects must be preserved: pending/expired deliveries would become eligible for permanent deletion. The migration alone is additive and does not enqueue old data.
Development
npm test
npm run build
npm run check
npm run check rebuilds the package and runs the Node test suite. The tests include byte-identical codec round trips, malformed-input rejection, import resolution, concurrency conflicts, release invariants, media-operation transitions, and a guard against site-specific code entering the shared package.
Generated files live in dist/ and are the only application files included in the npm tarball, in addition to npm's standard package metadata and this README.
License
0BSD. Use it, copy it, change it, ship it. Attribution is appreciated but not required.
Delivery dashboard
The delivery overview reads download requests, not completed downloads. Active transfers have ready packages, unexpired files and at least one valid link.
Before enabling DELIVERY_STORAGE_DASHBOARD=true, apply
dist/functions/delivery-public/migrations/0011_delivery_storage_dashboard.sql to
the consumer's delivery D1 database. In the consumer's existing scheduled handler,
call verifyDeliveryStorage(env, {maxCalls: 4}) from
@cardinali-studio/adminkit/functions/_shared/delivery-storage-dashboard.js after
housekeeping. Run the handler every 15 minutes: completed inventories refresh
hourly; larger inventories resume across invocations. Admin polling also resumes
due work, and “Verifica ora” forces a fresh check.
The read-only scan sums all B2 object versions and uploaded multipart parts, including hidden versions and unassigned keys. It stores only complete totals, retaining the previous snapshot on failure, and shows the last completion time. Concurrent B2 writes can change storage during the scan; this is a measured inventory, not the provider's billing statement. Dashboard checks never delete objects. The existing preservation policy remains independent of this feature.
Transfer dashboard and creation (2.2.42)
Both the full admin and mountDeliveryAdmin expose a Dashboard button above
New transfer. New transfers start with gallery and low-resolution conversion off,
JPG conversion selected, and a 60-day expiry. Existing packages keep their options.
Delivery options precede file selection; unavailable features are collapsed.
A site may supply deliveryPricing in SiteAdminConfig, or pricing in
DeliveryAdminOptions: storageUsdPerTb30Days, freeStorageGb,
downloadUsdPerGb, freeDownloadMultiplier, and ISO date verifiedAt.
The dashboard projects the last measured storage across the current calendar month;
it does not reconstruct past usage, subtract a shared free allowance, or infer
actual download bytes. Missing inventory is shown as unavailable, never zero.
The information dialog explains those limits. Rates are supplied by the consumer,
not inferred from storage credentials or provider names.
The dedicated delivery entry now initializes its own module configuration;
consumers must remove the old internal setSiteConfig workaround.
2.2.43 aligns the dedicated delivery navigation with the full shell: shared icons, accessible labels and text wrappers preserve both admin interface variants.
Illustrated Transfer guide (2.2.44)
The dashboard links to the bundled Italian guide in a new tab. Both public admin mount functions recognize ?adminkit-guide=transfer on the existing protected admin path. The reader loads lazily, has no Transfer API hooks, supports chapter deep links and contains seven locally captured screenshots with synthetic data. Screenshots and guide CSS ship in dist/app/guide/; consumers using Vite bundle the relative image URLs automatically. No external documentation host, new route or credentials are required. Source and screenshot provenance: src/app/guide/ and scripts/preview-transfer-guide.mjs.
Cloudflare Email Service and shared B2 buckets
Transfer email also supports DELIVERY_EMAIL_PROVIDER=cloudflare, the native
DELIVERY_EMAIL send binding and DELIVERY_EMAIL_ENABLED=true. Set
ENVIRONMENT=production, a verified DELIVERY_EMAIL_FROM, the canonical
DELIVERY_PUBLIC_BASE and the stable DELIVERY_SECRET; save owner/preferences
in Transfer settings. Request and cron Workers need the same configuration.
No Resend key is required. Use a binding restricted by sender (not only by owner
recipient) to send customer links on Workers Paid. Explicit provider selection
never falls back to Resend. Unknown/timeout results require review to prevent
unsafe automatic duplicate sends; documented rate/quota rejections can retry.
Non-production tests must inject an explicit mock binding.
DELIVERY_STORAGE_PREFIX=deliveries/consumer-a optionally isolates object keys,
signed URLs, inventories and version-aware deletion in a shared private bucket.
Independent projects must keep separate transfer databases. Consumers omitting
the setting retain existing paths. Keep the cleanup migration and persistent
cleanup records so outstanding signed writes are collected after deletion.