| name | api-and-auth |
| description | API architecture, oRPC contracts, auth middleware, plugin-client composition, session handling, and client-side auth. Use when adding API routes, creating middleware, calling other plugins in-process, or integrating auth in routes and UI. |
| metadata | {"sources":"api/src/index.ts,api/src/contract.ts,api/src/lib/auth.ts,host/src/services/auth.ts,host/src/services/plugins.ts,host/src/program.ts,ui/src/lib/auth.ts,ui/src/lib/api.ts"} |
API Architecture & Auth
Plugin Anatomy
The API is an every-plugin registered via createPlugin.withPlugins<PluginsClient>():
export default createPlugin.withPlugins<PluginsClient>()({
variables: z.object({ }),
secrets: z.object({ }),
context: z.object({ }),
contract,
initialize: (config, plugins) => Effect.promise(async () => {
return { db, upvoteService, publisher, auth, plugins };
}),
shutdown: (services) => Effect.promise(async () => { }),
createRouter: (services, builder) => ({
ping: builder.ping.handler(async () => ({ status: "ok", timestamp })),
}),
});
Fields: variables (public config), secrets (private env), context (per-request host context), contract (oRPC router), initialize (startup, returns services), createRouter (maps procedures to handlers), shutdown (cleanup). plugins in initialize gives typed factories for all other plugins.
oRPC Contract Design
Defined in api/src/contract.ts:
import { BAD_REQUEST, NOT_FOUND, UNAUTHORIZED } from "every-plugin/errors";
import { eventIterator, oc } from "every-plugin/orpc";
import { z } from "every-plugin/zod";
export const contract = oc.router({
ping: oc.route({ method: "GET", path: "/ping" }).output(
z.object({ status: z.literal("ok"), timestamp: z.iso.datetime() }),
),
upvoteThing: oc
.route({ method: "POST", path: "/upvotes" })
.input(z.object({ thingId: z.string() }))
.output(z.object({ thingId: z.string(), userId: z.string(), totalCount: z.number().int().nonnegative() }))
.errors({ UNAUTHORIZED, BAD_REQUEST }),
getUserVote: oc
.route({ method: "GET", path: "/upvotes/{thingId}/me" })
.input(z.object({ thingId: z.string() }))
.output(z.object({ thingId: z.string(), hasUpvote: z.boolean() }))
.errors({ UNAUTHORIZED }),
getUpvoteFeed: oc
.route({ method: "GET", path: "/upvotes/feed" })
.input(z.object({ limit: z.number().int().min(1).max(100).optional(), cursor: z.string().optional() }))
.output(z.object({ data: z.array(), meta: z.object({ total, hasMore, nextCursor }) })),
subscribeUpvotes: oc
.route({ method: "GET", path: "/upvotes/stream" })
.output(eventIterator(VoteEventSchema)),
});
Conventions: .errors() declares typed errors, {paramName} path params match Zod input keys, .output(eventIterator(Schema)) enables SSE streaming, export type ContractType = typeof contract for type generation.
Route Implementation
createRouter: (services, builder) => {
const { requireAuth } = createAuthMiddleware(builder);
return {
ping: builder.ping.handler(async () => ({
status: "ok",
timestamp: new Date().toISOString(),
})),
upvoteThing: builder.upvoteThing.use(requireAuth).handler(async ({ input, context }) => {
return await services.upvoteService.upvoteThing(input.thingId, context.userId);
}),
subscribeUpvotes: builder.subscribeUpvotes.handler(async function* ({ signal, lastEventId }) {
const iterator = services.publisher.subscribe("vote", { signal, lastEventId });
for await (const event of iterator) yield event;
}),
};
};
Handler receives { input, context, signal?, lastEventId? }.
Middleware
Create auth middleware with createAuthMiddleware(builder) in api/src/lib/auth.ts. Each middleware narrows the context type through .use() — no non-null assertions needed.
const { requireAuth } = createAuthMiddleware(builder);
builder.myRoute.use(requireAuth).handler(async ({ input, context }) => {
context.userId;
});
Available: requireAuth, requireAuthOrApiKey, requireRole("admin"), requireOrganization, requireOrgRole("owner"), requireApiKey. Apply via .use():
builder.authHealth.use(requireAuth).handler(...)
builder.adminAction.use(requireRole("admin")).handler(...)
See references/middleware.md for the full middleware table, org metadata validation, and typed context helpers.
Error Handling
Use ORPCError from every-plugin/errors:
import { ORPCError } from "every-plugin/orpc";
import { BAD_REQUEST, UNAUTHORIZED } from "every-plugin/errors";
throw new ORPCError("UNAUTHORIZED", {
message: "Authentication required",
data: { hint: "Sign in or provide an API key" },
});
Declare throwable errors in the contract via .errors({ UNAUTHORIZED, BAD_REQUEST }). Client-side errors are intercepted by onError in createRpcLink (ui/src/lib/api.ts).
Auth Plugin Architecture
The auth plugin is an external plugin loaded in Phase 0 of the host's initialization:
- Phase 0 (
host/src/services/plugins.ts): Load auth plugin, create authClient factory.
- Phase 1: Load all non-API plugins.
- Phase 2: Load API plugin with
pluginsClient (includes auth + all other plugin factories).
The host mounts the auth handler at /api/auth/*:
export function registerAuthHandler(app, plugins) {
const services = getAuthServices(plugins);
if (!services) return;
app.on(["POST", "GET"], "/api/auth/*", (c) => services.handler(c.req.raw));
}
Session Middleware
Runs on every non-auth request. Resolves the session from cookies and sets Hono request context:
export function createSessionMiddleware(plugins) {
return async (c, next) => {
if (c.req.path.startsWith("/api/auth/")) return next();
c.set("reqHeaders", c.req.raw.headers);
const authClient = authClientFactory({ reqHeaders });
const [session, context] = await Promise.all([
authClient.getSession(),
authClient.getContext(),
]);
c.set("user", session?.user ?? context.user ?? null);
c.set("session", session?.session ?? null);
c.set("walletAddress", context.near.primaryAccountId ?? null);
c.set("apiKey", context.apiKey ?? null);
c.set("organizationId", context.organization?.activeOrganizationId ?? null);
await next();
};
}
If resolution fails, all values are null — requireAuth routes reject with UNAUTHORIZED.
The context is transformed for the API plugin via buildPluginContext(), with the full organization envelope from Better Auth:
export function buildPluginContext(c) {
return {
userId: user?.id, user: user ?? undefined,
organization: context.organization ?? undefined,
apiKey: apiKey ?? undefined,
reqHeaders: c.get("reqHeaders"),
getRawBody: c.get("getRawBody"),
};
}
Auth in API Routes
The API plugin receives auth in initialize:
initialize: (config, plugins) => Effect.promise(async () => {
const { auth, ...restPlugins } = plugins;
return { auth, plugins: restPlugins, ... };
})
Use getAuthClient() for in-process calls:
import { getAuthClient, createAuthMiddleware } from "./lib/auth";
const authClient = getAuthClient(services, { reqHeaders: context.reqHeaders });
const session = await authClient.getSession();
AuthCapableServices requires an auth factory. If unavailable, getAuthClient() throws.
Auth on the Client
Create the client in ui/src/lib/auth.ts:
export function createAuthClient(runtimeConfig) {
return betterAuth.createClient({
baseURL: runtimeConfig.authBaseUrl,
plugins: [siwn({ recipients, networkId }), passkey(), organization(), admin(), apiKey(), anonymous(), phone()],
});
}
In route code:
import { useAuthClient, sessionQueryOptions } from "@/app";
const authClient = useAuthClient();
The sessionQueryOptions() helper provides standard TanStack Query config:
const session = await queryClient.ensureQueryData(
sessionQueryOptions(authClient, context.session),
);
Auth Route Guard
The _authenticated.tsx layout redirects unauthenticated users:
export const Route = createFileRoute("/_layout/_authenticated")({
beforeLoad: async ({ context, location }) => {
const { queryClient, authClient } = context;
const session = await queryClient.ensureQueryData(
sessionQueryOptions(authClient, context.session),
);
if (!session?.user) {
throw redirect({ to: "/login", search: { redirect: location.href } });
}
return { auth: { isAuthenticated: true, user: session.user, session: session.session } };
},
component: AuthenticatedLayout,
});
Plugin Client Composition
The host uses two-phase loading so API plugins can call other plugins in-process:
- Phase 0: Auth plugin →
authClient factory
- Phase 1: All non-API plugins →
pluginsClient map of createClient factories
- Phase 2: API plugin with all plugin factories merged
const pluginsClient = { ...pluginClients };
if (authClient) pluginsClient.auth = authClient;
const baseApi = await loadPluginEntry(runtime, apiEntry, integrityRegistry, pluginsClient);
Calling Plugins from API Routes
The API plugin receives plugins in initialize:
initialize: (config, plugins) => Effect.promise(async () => {
const authClient = plugins.auth({ reqHeaders: someHeaders });
const session = await authClient.getSession();
return { auth: plugins.auth, plugins, ... };
})
API-Owned Registry Pattern
When the API owns the durable registry and a plugin owns the semantic payload:
const provider = thingProviders[input.pluginId];
if (!provider) {
throw new ORPCError("BAD_REQUEST", { message: `Unsupported pluginId: ${input.pluginId}` });
}
Rules: API owns thingId, pluginId, timestamps. Plugin owns type and payload. Keep one SSE stream per concept and filter server-side.
Effect and DB Lifecycle
Prefer Layer for long-lived resources (DB, service singletons) and Effect for the work itself. Use runEffect() to bridge Effect and async handlers with clean ORPC error boundaries — unwraps ORPCError from Effect and converts unknown errors to INTERNAL_SERVER_ERROR:
import { runEffect } from "@/lib/context";
const result = await runEffect(services.myService.doSomething(input));
Best practices: Keep service interfaces Effect-native, bridge to async only at the handler boundary via runEffect(). Use Context.Tag for DI between services. Initialize long-lived resources in initialize and return them as services.
SSR Proxy Client
createPluginsClient() creates a Proxy that merges the API client with all plugin clients:
export function createPluginsClient(result, context) {
const apiClient = result.api?.createClient(context);
const pluginClients = {};
for (const [key, plugin] of Object.entries(result.plugins)) {
if (key === "api") continue;
pluginClients[key] = plugin.createClient(context);
}
if (result.authClient) pluginClients.auth = result.authClient(context);
return new Proxy(apiClient, {
get(target, key) {
if (typeof key === "string" && key in pluginClients) return pluginClients[key];
return Reflect.get(target, key);
},
});
}
Generated Types
See references/generated-types.md for the full table — files, contents, and regeneration triggers.
SSE Notes
Prefer a single publisher channel per concept and filter on the consumer side:
const iterator = services.publisher.subscribe("thing", { signal, lastEventId });
for await (const event of iterator) {
if (input.pluginId && event.pluginId !== input.pluginId) continue;
yield event;
}
How Routes Are Mounted
The host (host/src/program.ts) creates RPC and OpenAPI handlers from each plugin's router, mounted at /api/rpc/<plugin-namespace>. The session middleware runs on /api/* before the RPC handlers, ensuring context is set.