Skip to content

Application-owned service composition ​

Read the exact installed SDK/Admin versions. The Native Admin guide is the authority for generated collection bindings and retained console behavior.

The app owns fetch, native database handles, pools, auth options and deployment. Select generated modules by importing them; omitted modules stay out of the bundle. Keep application routing source outside the generated directory. The auth template, surface contracts and media wiring describe the selected capabilities.

Mount auth (including discovery), consent, Admin, MCP and REST in that order, matching 0.2. authRoutes owns both auth and consent paths. Supply one lazy, request-local resolver to those surfaces, memoizing read-only and fresh authority separately so a cached read cannot authorize a write. All cookie-session mutations use the shared origin check. A custom identity supplies the same verified Caller; absent directory/role facets remain absent. Never construct a system Caller from credentials or request input.

Generate Env from the Wrangler config; do not hand-write it. Declare secrets under secrets.required, run pnpm exec wrangler types --include-runtime=false, add worker-configuration.d.ts to tsconfig.json and commit it. @cloudflare/workers-types still supplies runtime types. Rerun after every binding change; wrangler types --include-runtime=false --check fails on drift. Once secrets.required is set, wrangler dev loads only those keys from .dev.vars/.env, so declare every secret the app reads.

ts
// src/worker.ts — application-owned; choose imports for the features you use.
import { authLoader, resolveCaller } from '@aotter/mantle/auth';
import { authRoutes } from '@aotter/mantle/auth/routes';
import { withAuthTasks } from '@aotter/mantle/auth/hardening';
import { checkOrigin, surfaceFailure, type Caller } from '@aotter/mantle/contracts';
import { admin, adminShell } from '@aotter/mantle-admin/server';
import { adminMcpApps } from '@aotter/mantle-admin/mcp';
import { entries } from '@aotter/mantle-admin/server/entries';
import { adminBindings } from '../generated/admin-bindings.js';
import { mcpAppCatalogues } from '../generated/admin-descriptors.js';
import { mcp } from '../generated/mcp.js';
import { rest } from '../generated/rest.js';
import { handlers } from './handlers.js'; // omit when there are no ref Procedures

// Env comes from worker-configuration.d.ts: declare DB, ASSETS, the email provider binding
// and the PUBLIC_ORIGIN, BETTER_AUTH_SECRET and ADMIN_EMAIL secrets in Wrangler.
// createAuth(env) is exported by the app's src/auth.ts (see the auth template).
let auth: import('@aotter/mantle/auth').AuthLoader<ReturnType<typeof import('./auth.js').createAuth>> | undefined;
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return withAuthTasks(ctx.waitUntil.bind(ctx), async () => {
      auth ??= authLoader(() => import('./auth.js').then(m => m.createAuth(env)));
      const headers = new Headers(); // retain Better Auth's sliding-session cookies
      let authority: Promise<Caller> | undefined, readAuthority: Promise<Caller> | undefined;
      const resolve = (readOnly: boolean) => resolveCaller(request, auth!, {
        readOnly,
        audience: [`${env.PUBLIC_ORIGIN}/mcp`, `${env.PUBLIC_ORIGIN}/mcp/admin`],
        responseHeaders: headers,
      }).then(value => { checkOrigin(request, value); return value; });
      const caller = (_request?: Request, _env?: Env, classification = { readOnly: false }) =>
        classification.readOnly ? readAuthority ??= resolve(true) : authority ??= resolve(false);
      const options = { db: (env: Env) => env.DB, caller, handlers };
      const bindings = adminBindings({ handlers }); // use adminBindings() without ref Procedures
      const routes = [
        authRoutes(auth, { methods: [{ kind: 'email-otp' }], resolver: caller }),
        admin(bindings.metadata, bindings.views, bindings.operations, {
          ...options, developerViews: bindings.developerViews, routes: entries(bindings.schemas, bindings.adapter),
        }),
        mcp({ ...options, apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin }) } }),
        rest(options),
      ];
      try {
        for (const route of routes) {
          const response = await route(request, env);
          if (!response) continue;
          if (!headers.getSetCookie().length) return response;
          const result = new Response(response.body, response);
          for (const cookie of headers.getSetCookie()) result.headers.append('set-cookie', cookie);
          return result;
        }
        // Serves the built Admin shell for /admin and its client routes; undefined for any other path.
        const shell = await adminShell(request, path => env.ASSETS.fetch(new Request(new URL('/admin/' + path, request.url), request)));
        if (shell) return shell;
        // Your public frontend/web.ts routes can use an anonymous Caller without
        // loading auth; their generated SQL still controls publication visibility.
        // Mounted here, after every Mantle surface, a root catch-all page (/:slug)
        // cannot shadow /admin, /mcp or REST. Mount them earlier only behind a
        // guard that skips those paths, as the app shell does.
        return new Response('Not found', { status: 404, headers: { 'cache-control': 'no-store' } });
      } catch (error) { return surfaceFailure(error); }
    });
  },
};

Keep the MCP and REST entries together for the normal symmetric path. For a deliberate asymmetric exception, remove the omitted surface's imports and array entry. mcp.ts exists only when the manifest has an MCP surface and requires the documented optional MCP peer. An application that deliberately does not mount MCP need not import generated mcp.ts; outside the entry import graph, it contributes nothing to the bundle. Pass the same handlers to each selected surface that exposes ref Procedures. Bind site/media/identity capabilities through the selected Admin/MCP options; do not expose native auth administrative routes without authRoutes or guardAuthAdminRequest. withAuthTasks retains background callbacks on the current request's context; authHardening configures the background handler. Select --admin to emit admin-bindings.ts. Admin's API is a separate @aotter/mantle-admin package; the SDK never imports it. The API handles only /admin/api. adminShell (in the block above) serves the built shell for /admin and client routes, including /admin/sign-in; it sets frame-ancestors 'none', X-Frame-Options: DENY, no-store and nosniff on the shell, answers a missing file under /admin with a 404 Response, and returns undefined for other paths and non-GET requests. The app owns the rest of the CSP and the asset cache headers. Collection statistics use the generated shared statisticsView: its collection parameter selects only a generated table and visibility policy.

Workers assets and secrets ​

mantle generate --admin --wrangler wrangler.jsonc sets the selected assets directory. A new assets block defaults to binding: "ASSETS", run_worker_first: true and html_handling: "none". Existing blocks keep their binding and routing; when run_worker_first is already true, a missing html_handling defaults to none. A different binding needs a custom Admin HTML loader. Deploy the built assets and Worker together.

mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --admin updates the split-build Wrangler keys (main and base_dir under dist/worker), ensures nodejs_compat and defaults to global_fetch_strictly_public (public fetch routing) and, with --admin, assets.directory: mantle bundle builds Admin into dist/client/admin, served at /admin. Configure existing assets blocks explicitly; JSONC comments and unrelated settings survive regeneration:

jsonc
{
  "assets": {
    "directory": "dist/client",
    "binding": "ASSETS",
    "run_worker_first": true,
    "html_handling": "none"
  },
  "d1_databases": [{ "binding": "DB", "database_name": "your-database", "database_id": "your-id" }]
}

run_worker_first lets the application supply shell/security/cache headers; none keeps index.html available by name. adminShell(request, assets) passes paths relative to the Admin directory, so the app fetches them under /admin/: path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, request.url), request)). Admin assets are not a public database API. PostgreSQL uses application-owned Hyperdrive/postgres.js wiring, with Hyperdrive query caching disabled for authority and mutation visibility.

Commit .dev.vars.example containing variable names and placeholders only:

dotenv
PUBLIC_ORIGIN=http://localhost:8787
ADMIN_EMAIL=you@example.test
BETTER_AUTH_SECRET=replace-with-a-random-secret

Copy it to ignored .dev.vars; use deployment secrets for production. Configure an app-owned EmailSender and call its send({ to, subject, text, html?, locale, category? }) from Better Auth's email callbacks. Adapt the auth template's MAIL.otp/MAIL.link callbacks to this sender; it is an object, not a .dev.vars value. The auth module wires ADMIN_EMAIL to the bootstrap helper, which requires verified email. Name the variables as your auth.ts reads them (the app shell uses these three) and keep the trusted origin consistent.

Scheduling and Bun ​

Scheduling remains app-owned; schedule/lifecycle Triggers are refused. Import only the desired generated sweepExpired entry in scheduled(), pass a system Caller with a reason, and choose an application-owned page budget for each invocation; see the TTL contract. Configure triggers.crons yourself; toCloudflareCron converts POSIX schedules without adding a dispatcher. Keep larger sweeps' cursors for a later invocation instead of scanning without a bound.

On Bun, reuse a process-owned Database or SQL pool and call the same generated surfaces from Bun.serve({fetch}). Bun runs the server as TypeScript, so mantle bundle builds only Admin for it: the bun-* target that generated/.mantle-inputs.json records skips the Worker bundle, and Admin goes to dist/client/admin (--assets as on Workers) when the server imports @aotter/mantle-admin/server. Add it as the build script and run it before starting the server:

json
{ "scripts": { "build": "mantle bundle src/server.ts dist" } }

The optional host reader is independent of the query target:

ts
import { bunAdminAssets } from '@aotter/mantle/bun/assets';
const assets = bunAdminAssets('dist/client/admin');
const bindings = adminBindings({ handlers });
const serveAdmin = admin(bindings.metadata, bindings.views, bindings.operations, {
  db: () => db, caller, handlers, routes: entries(bindings.schemas, bindings.adapter),
});
// Call assets(path) from your separate shell/static route. adminShell(request, reader) wants
// paths without a leading slash and a Response (404 when missing), so adapt the reader:
const reader = async (path: string) => (await assets('/' + path)) ?? new Response(null, { status: 404 });

The reader returns a native Response or null, rejects traversal/control characters and escaping symlinks; the app owns shell, CSP and cache headers. Use ignored .env on Bun.

Admin's build tools and UI packages are dependencies of @aotter/mantle-admin, so a production install carries them (about 65 MB). For a container image, use a multi-stage build: the build stage installs everything and runs mantle bundle and bun build src/server.ts --target bun --outdir dist/server; the runtime stage copies only dist/ (plus any files the server reads at runtime) and starts bun dist/server/server.js. Neither the Admin assets nor the bundled server read node_modules once built. Run cron work with your native scheduler; neither imports nor ordinary requests execute DDL, migrations or cron work. OAuth resource rows are inserted on first OAuth use from runtime configuration, with no deploy step; auth initialization and cookie/bearer/MCP/REST requests do no resource work.

Commit generated output and verify it in CI ​

Commit every file written by mantle generate: TypeScript, schema.sql, optional seed SQL and Wrangler edits. Never hand-edit generated output. Every emitted TypeScript/SQL file carries the ownership header. Add the line printed by generation to .gitattributes, for example:

gitattributes
generated/** linguist-generated=true

Resolve generated merge conflicts by merging authored manifests/options first, then regenerating with the same options. Review the SQL/policy diff and write explicit migrations before deployment. Keep dist/ (the mantle bundle output) untracked. Deployment uses committed generated source; it does not regenerate.

sh
# CI, with the project's exact installed SDK and peer versions:
pnpm exec mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --admin --check
pnpm exec tsc --noEmit
pnpm exec mantle bundle src/worker.ts dist   # dist/worker, and dist/client/admin with --admin

--check regenerates in memory, lists missing/changed/obsolete SDK-owned files and stale Wrangler build settings, exits nonzero, and writes nothing. Unmarked application files are preserved. TOML generation prints manual edits, so an exact Wrangler --check requires JSON/JSONC. Use all the same --site, --auth and --admin options as normal generation. The check needs no database, network access, credentials or deployment. Imported configuration modules must remain pure: exporting authOptions(env) avoids constructing Better Auth at build time.