Auth Claims
Features inject identity facts into the JWT.
What it shows
Section titled “What it shows”r.authClaims(fn)contributing toSessionUser.claims- Claims recomputed on login and tenant switch
Source
Section titled “Source”Feature entry point: src/feature.ts.
cd samples/recipes/auth-claimsbun testSource code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Auth Claims Sample//// Shows how features contribute identity facts into SessionUser.claims via// `r.authClaims()`, and how handlers read those facts back with typed// handles from `r.claimKey()` + the `readClaim(user, handle)` helper.//// Why the handle pattern: reading `user.claims["teams:teamId"] as string`// hand-codes the qualified name (magic string, breaks under a rename) and// casts without the compiler's help. A declared handle threads the qualified// name and JS type through `readClaim<T>(user, handle)`, so a rename in// `r.claimKey("teamId", ...)` cascades to every call site, and `readClaim`// narrows the return type to `string | undefined` automatically.//// Design rules the sample bakes in:// 1. Auto-prefix means cross-feature keys cannot collide. Feature "teams"// returning `{ teamId: ... }` lands at `"teams:teamId"`. Feature// "billing" returning the same inner key lands at `"billing:teamId"`.// Both coexist.// 2. Best-effort errors: if one feature's hook throws, that feature's// claims are missing from the JWT, but the login still succeeds.// Identity-facts are convenience, not access-gates.// 3. Hook context is trimmed (AuthClaimsContext, not HandlerContext) —// login is a read, so appendEvent/archive/tz aren't available. queryAs// + db cover the cross-feature lookups a claims-hook realistically// needs.// 4. Declaring claim keys via r.claimKey also turns on a runtime drift-// check: if a hook returns an undeclared inner-key, the resolver logs// a warning (the claim still lands in the JWT — this is typo// protection, not strict schema).
import { createEntity, createTextField, defineFeature, type SessionUser, type TenantId,} from "@cosmicdrift/kumiko-framework/engine";
export const teamMembershipEntity = createEntity({ table: "read_sample_auth_team_memberships", fields: { userId: createTextField({ required: true }), teamId: createTextField({ required: true }), },});
export const betaFlagsEntity = createEntity({ table: "read_sample_auth_beta_flags", fields: { userId: createTextField({ required: true }), flag: createTextField({ required: true }), },});
// Plain in-memory state that the hooks read from — keeps the sample focused// on the claim mechanism, not on DB seeding. A real feature would SELECT// from its own table via ctx.db.export type TeamStore = Map<string, string>;export type BetaFlagStore = Map<string, readonly string[]>;
// Feature: Teams. Declares a typed `teamId` claim and a hook that fills it.// The setup callback returns an `exports` object carrying the handles — the// test (or any other feature) imports them via `teamsFeature.exports.Claims`// without knowing or re-typing the qualified name.export function makeTeamsFeature(store: TeamStore) { return defineFeature("teams", (r) => { const teamId = r.claimKey("teamId", { type: "string" });
r.authClaims(async (user: SessionUser) => { const value = store.get(user.id); return value ? { teamId: value } : {}; });
return { Claims: { teamId } as const }; });}
// Feature: BetaFlags. Array claim — shows the handle's type parameter// narrowing for non-scalar values.export function makeBetaFlagsFeature(store: BetaFlagStore) { return defineFeature("betaFlags", (r) => { const flags = r.claimKey("flags", { type: "string[]" });
r.authClaims(async (user: SessionUser) => { const value = store.get(user.id); return value && value.length > 0 ? { flags: value } : {}; });
return { Claims: { flags } as const }; });}
// Feature: Broken. Demonstrates the best-effort policy — this hook always// throws. The tests assert that a broken hook does not fail the login and// the other features' claims still land in the JWT.export function makeBrokenFeature() { return defineFeature("broken", (r) => { r.authClaims(async () => { throw new Error("pretend the DB just disappeared"); }); });}
// Feature: Drift — declares `teamId` but its hook returns `rouge` as well.// Exercises the drift-warning that r.claimKey unlocks: the resolver logs a// warn for `rouge` but still merges both into the JWT (best-effort).export function makeDriftFeature(shouldFire: { value: boolean }) { return defineFeature("drift", (r) => { r.claimKey("teamId", { type: "string" });
r.authClaims(async () => { if (!shouldFire.value) return {}; return { teamId: "declared", rouge: "undeclared" }; }); });}
// Helper used by tests to build a fake session for a given user/tenant.// The sample avoids requiring the full auth-email-password feature —// the claim-resolver mechanism is tested directly.export function fakeSession(userId: string, tenantId: TenantId): SessionUser { return { id: userId, tenantId, roles: ["User"] };}📄 On GitHub: samples/recipes/auth-claims/src/feature.ts