@usecarte/better-auth
v0.0.1
Published
better-auth integration for Carte: derive a typed Carte Context from a better-auth session for both handler routes and Server Actions.
Maintainers
Readme
@usecarte/better-auth
better-auth integration for Carte. Bridges a better-auth session into Carte's Context, so you write a typed map({ user, session }) => yourContext callback instead of hand-rolling auth.api.getSession({ headers }) plus null-handling in every route.
Two factories — one for handler routes (Request in scope), one for Server Actions (Request not in scope, headers come from next/headers or equivalent).
Install
pnpm add @usecarte/better-authPeer deps: @usecarte/core and better-auth >= 1.0.0. The package is server-only — no React peer.
Handler route (Next App Router, Astro, Workers, Bun, Deno, Node)
// app/api/carte/route.ts
import { createCarteHandler } from "@usecarte/server";
import { betterAuthContext } from "@usecarte/better-auth";
import { auth } from "@/lib/auth";
import { carte } from "@/lib/carte";
import { uiAdapter } from "@/lib/ui";
export const POST = createCarteHandler({
carte,
uiAdapter,
context: betterAuthContext({
auth,
map: ({ user, session }) => ({
userId: user.id,
role: user.role ?? "guest",
tenantId: session.activeOrganizationId ?? undefined,
}),
}),
llm: async (systemPrompt, userMessage) => {
/* call your LLM, return JSON string */
},
});Same wiring works on createCartePanelParamsHandler for the panel-param mutation route.
Server Action (Next.js App Router)
headers() from next/headers returns Promise<ReadonlyHeaders> since Next 15. Pass async () => await headers() to getHeaders:
// app/actions.ts
"use server";
import { headers } from "next/headers";
import { createCarteAction } from "@usecarte/server";
import { betterAuthActionContext } from "@usecarte/better-auth";
import { auth } from "@/lib/auth";
import { carte } from "@/lib/carte";
import { uiAdapter } from "@/lib/ui";
export const askCarte = createCarteAction({
carte,
uiAdapter,
context: betterAuthActionContext({
auth,
getHeaders: async () => await headers(),
map: ({ user, session }) => ({
userId: user.id,
role: user.role ?? "guest",
}),
}),
llm: async (systemPrompt, userMessage) => {
/* call your LLM, return JSON string */
},
});For frameworks other than Next.js, pass whatever yields a Headers instance for the current request — e.g. getHeaders: () => yourFramework.requestHeaders().
Unauthenticated handling
When auth.api.getSession() returns null, both factories throw:
new CarteExecutionError({
category: "context_unauthorized",
panelIndex: CONTEXT_PHASE_PANEL_INDEX,
queryId: CONTEXT_PHASE_QUERY_ID,
})The default createCarteHandler / createCartePanelParamsHandler error response maps category === "context_unauthorized" to a 401 automatically. No onError needed for the common case.
If you supply your own onError, branch on the category:
import { CarteExecutionError } from "@usecarte/core";
createCarteHandler({
// ...
onError: (err) => {
if (err instanceof CarteExecutionError && err.category === "context_unauthorized") {
return new Response("Sign in required", { status: 401 });
}
// fall through to your default
},
});The cause is preserved on Error.prototype.cause for server-side logging only — toModelSafeJSON() excludes it. See Carte's security model for the full Invariant 2 story.
Type inference
The map callback's payload type is inferred from auth.$Infer.Session, so any additionalFields and plugin extensions on your better-auth instance flow through automatically:
// In your better-auth setup:
export const auth = betterAuth({
user: {
additionalFields: { tenantId: { type: "string", required: true } },
},
plugins: [admin()],
});
// In your handler:
betterAuthContext({
auth,
map: ({ user }) => ({
userId: user.id,
role: user.role, // narrowed via the admin plugin
tenantId: user.tenantId, // narrowed via additionalFields
}),
});If auth.$Infer.Session isn't typed (a hand-rolled mock, an older better-auth version), the payload falls back to { user: unknown; session: unknown } so the factory still type-checks — but you lose the narrowing.
What this package isn't
- It doesn't mount better-auth's own
/api/auth/*route. That's the consumer's responsibility — see the relevant integration page on better-auth's docs. - It doesn't ship per-entry RBAC helpers. Use Carte's existing
access(ctx)predicates on each entry. The integration's job is derivingctx, not gating on it. - It doesn't touch the client. better-auth has its own
useSession(); Carte'suseCartehook never reads context. There's nothing to bridge on the client.
