Skip to content

Expose a public, anonymous-readable endpoint

A logged-in user has a tenant ID in their JWT; the framework reads it and ctx.db is automatically tenant-scoped. An anonymous caller has neither. To expose a public endpoint, you tell the framework two things: where to get the tenant ID from, and which handlers anonymous callers may reach.

null tenant id not anonymous granted Anonymous visitor acme.status.example.com API route no JWT, no tenant in context tenantResolver host / subdomain / header → tenant id No tenant request rejected Access check roles: ["anonymous"] — not openToAll Query handler ctx.db is tenant-scoped Field-level read filter admin-only columns stay invisible
  • You’ve read Multi-tenancy so the tenant-DB invariant is clear.
  • Your app uses subdomains, custom hosts, or a header to identify tenants in public contexts (e.g. acme.status.example.com).

Configure anonymousAccess on the app entrypoint with a tenant resolver:

src/app/server.ts
import { runProdApp } from "@cosmicdrift/kumiko-dev-server";
import { statusFeature } from "../features/status/feature";
await runProdApp({
features: [statusFeature],
anonymousAccess: {
tenantResolver: async ({ db, request }) => {
const host = request.headers.get("host") ?? "";
const subdomain = host.split(".")[0];
const tenant = await db.query("tenant", "by-slug", { slug: subdomain });
return tenant?.id ?? null;
},
},
});

In the feature, declare the public handler with roles: ["anonymous"]:

src/features/status/handlers/components.query.ts
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
export const listPublicComponents = defineQueryHandler({
name: "components-list",
schema: z.object({}),
access: { roles: ["anonymous"] },
handler: async (_query, ctx) => {
return ctx.db.list("component", { where: { showOnPage: true } });
},
});

ctx.db is still tenant-scoped, the resolver supplied the tenant ID, so reads filter automatically. The anonymous caller never sees data from another tenant.

Why roles: ["anonymous"] and not openToAll

Section titled “Why roles: ["anonymous"] and not openToAll”

{ openToAll: { reason } } means any logged-in user can call this. It does not mean anonymous. The split is deliberate: a regression that turns auth off should not be one character away from a publicly-callable endpoint. A code review can grep for "anonymous" and see every public surface; openToAll is internal-only.

  • Field-level access still applies. A field with read: { roles: ["User"] } is invisible to anonymous callers. If a field should appear on the public page, include "anonymous" in the read roles (or omit the access block entirely).
  • The resolver runs on every request. Cache the slug→tenant lookup if your traffic is high, Kumiko does not cache it for you.
  • Anonymous can query, but not write. Public writes (e.g. “subscribe to incident updates”) need a handler that explicitly sets roles: ["anonymous"] plus its own validation; default-deny applies until you opt in.
  • Multi-tenancy, the tenant resolver is the public-page counterpart to JWT-driven tenancy.
  • Auth and permissions, why "anonymous" is a first-class role rather than an openToAll shorthand.