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 mounts auth-foundation and a tenant-resolver provider.
  • Your app uses subdomains, custom hosts, or a header to identify tenants.

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.

src/features/subdomain-routing/feature.ts
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"]:

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) =>
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.

  • 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 not write. Public writes (e.g. “subscribe to incident updates”) need a handler that explicitly sets roles: ["anonymous"], validates the payload, and declares an IP-based rateLimit such 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, set tenantlessAnonymous: true on the write handler. The call then runs as the anonymous visitor under the system tenant. Boot requires access: { roles: ["anonymous"] } and a real rateLimit. The exemption only covers a single POST /api/write. It does not apply to /api/batch, when the client sends an X-Tenant header, or when the request has the wrong tenant. Because the call runs under the system tenant, ctx.db only sees rows of the system tenant.
  • 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.