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.
Prerequisites
Section titled “Prerequisites”- You’ve read Multi-tenancy so the tenant-DB invariant is clear.
- Your app mounts
auth-foundationand a tenant-resolver provider. - Your app uses subdomains, custom hosts, or a header to identify tenants.
The code
Section titled “The code”Register a provider feature. Resolver and existence providers come from
auth-foundation extension points. The complete typed provider and cache
implementation is in the auth-foundation providers recipe;
the snippet below only shows the contract.
import { EXT_TENANT_EXISTENCE, EXT_TENANT_RESOLVER, type TenantExistenceProvider, type TenantResolverProvider,} from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
export const routingFeature = defineFeature("subdomain-routing", (r) => { r.requires("auth-foundation"); const resolver: TenantResolverProvider = { trust: "authoritative", build: () => async (context) => { const host = (context as { req: { header(name: string): string | undefined } }).req.header("Host") ?? ""; const subdomain = host.split(":")[0]?.split(".")[0]; return subdomain ? lookupTenantId(subdomain) : null; }, }; const existence: TenantExistenceProvider = { build: () => (tenantId) => tenantExists(tenantId), }; r.useExtension(EXT_TENANT_RESOLVER, "subdomain", resolver); r.useExtension(EXT_TENANT_EXISTENCE, "database", existence);});In the feature, declare the public handler with roles: ["anonymous"]:
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) => ctx.db.selectMany(componentTable, { 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.
Common gotchas
Section titled “Common gotchas”- Field-level access still applies. A field with
read: ["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 to tenant lookup in the provider if traffic is high; the framework does not cache it for you.
- Anonymous can
query, but notwrite. Public writes (e.g. “subscribe to incident updates”) need a handler that explicitly setsroles: ["anonymous"], validates the payload, and declares an IP-basedrateLimitsuch as{ per: "ip+handler", limit: 30, windowSeconds: 60 }; default-deny applies until you opt in. - A host without a tenant answers
tenant_required. For a public write that has to work on such a host, for example a platform-wide cancellation form, settenantlessAnonymous: trueon the write handler. The call then runs as the anonymous visitor under the system tenant. Boot requiresaccess: { roles: ["anonymous"] }and a realrateLimit. The exemption only covers a singlePOST /api/write. It does not apply to/api/batch, when the client sends anX-Tenantheader, or when the request has the wrong tenant. Because the call runs under the system tenant,ctx.dbonly sees rows of the system tenant.
See also
Section titled “See also”- 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 anopenToAllshorthand.