UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

356 lines (270 loc) 12.3 kB
--- 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>()`: ```ts export default createPlugin.withPlugins<PluginsClient>()({ variables: z.object({ /* typed config */ }), secrets: z.object({ /* typed env vars, defaults for dev */ }), context: z.object({ /* per-request context injected by host */ }), contract, initialize: (config, plugins) => Effect.promise(async () => { return { db, upvoteService, publisher, auth, plugins }; }), shutdown: (services) => Effect.promise(async () => { /* cleanup */ }), 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`: ```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 ```ts 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. ```ts const { requireAuth } = createAuthMiddleware(builder); builder.myRoute.use(requireAuth).handler(async ({ input, context }) => { context.userId; // string narrowed by middleware }); ``` Available: `requireAuth`, `requireAuthOrApiKey`, `requireRole("admin")`, `requireOrganization`, `requireOrgRole("owner")`, `requireApiKey`. Apply via `.use()`: ```ts 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`: ```ts 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: 1. **Phase 0** (`host/src/services/plugins.ts`): Load auth plugin, create `authClient` factory. 2. **Phase 1**: Load all non-API plugins. 3. **Phase 2**: Load API plugin with `pluginsClient` (includes auth + all other plugin factories). The host mounts the auth handler at `/api/auth/*`: ```ts // host/src/services/auth.ts 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: ```ts // host/src/services/auth.ts 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: ```ts 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`: ```ts initialize: (config, plugins) => Effect.promise(async () => { const { auth, ...restPlugins } = plugins; return { auth, plugins: restPlugins, ... }; }) ``` Use `getAuthClient()` for in-process calls: ```ts 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`: ```ts export function createAuthClient(runtimeConfig) { return betterAuth.createClient({ baseURL: runtimeConfig.authBaseUrl, plugins: [siwn({ recipients, networkId }), passkey(), organization(), admin(), apiKey(), anonymous(), phone()], }); } ``` In route code: ```ts import { useAuthClient, sessionQueryOptions } from "@/app"; const authClient = useAuthClient(); ``` The `sessionQueryOptions()` helper provides standard TanStack Query config: ```ts const session = await queryClient.ensureQueryData( sessionQueryOptions(authClient, context.session), ); ``` ### Auth Route Guard The `_authenticated.tsx` layout redirects unauthenticated users: ```ts 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: 1. **Phase 0**: Auth plugin `authClient` factory 2. **Phase 1**: All non-API plugins `pluginsClient` map of `createClient` factories 3. **Phase 2**: API plugin with all plugin factories merged ```ts // host/src/services/plugins.ts 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`: ```ts 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: ```ts 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`: ```ts 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: ```ts 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: ```ts 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.