Imported from suiramdev/freenary (
packages/api/AGENTS.md). Install upstream withnpx skills add suiramdev/freenary --skill api. Copyright stays with the author.
packages/api — API Layer
Typed RPC procedures consumed by apps/web (via @orpc/tanstack-query) and mounted by apps/server (via RPCHandler and OpenAPIHandler).
Stack
- oRPC (
@orpc/server) — typed procedure builder with middleware support. - Zod 4 — input/output validation.
- Better Auth — session resolution via
createContext.
Layout
src/
index.ts # Procedure builder: publicProcedure, protectedProcedure
context.ts # createContext: resolves session from Elysia request headers
routers/
index.ts # appRouter: all procedure definitions, exported type
Conventions
- Every procedure is defined in
src/routers/and composed intoappRouter. - Use
publicProcedurefor unauthenticated endpoints,protectedProcedurefor session-guarded ones.protectedProcedurethrowsUNAUTHORIZEDif no session. - Input validation uses Zod schemas. Output types are inferred — avoid manual typing.
apps/webimports the router type (AppRouter,AppRouterClient) for full client inference — never the implementation.- The context depends on Elysia's
Contexttype. If the server framework changes,context.tsis the only file that needs updating. - Return slugs and enums, never display strings.
apps/webis translated and the API is not: a human-readablelabelin a response is English the client cannot translate and must ignore. Send the stable key ("rent-mortgage","ACTIVE") and let the web app map it to a message — the spending taxonomy works this way withapps/web/src/entities/category/model/taxonomy-labels.ts. Text that originates with the user, the bank or the provider is data, not UI copy, and passes through as-is. - Bank providers live in
src/providers/<id>/behindBankingProvider(src/providers/types.ts); routers and sync only ever see that interface, andsrc/providers/registry.tspicks the default fromBANKING_PROVIDER. Per-user provider identities are persisted bysrc/lib/bank-provider-user.ts, never by an adapter. - Categorisation needs
bun run build:databefore the dev stack.data/merchants.jsonl.gzis gitignored and only the productionapps/server/Dockerfilebuilds it;.docker/dockerfile.devdoes not. Without it the dictionary stage is a permanent no-op — every well-known merchant falls through to the bank-code rules insrc/categorisation/keywords/and, whenTRANSACTION_CLASSIFIERis set, to the classifier insrc/categorisation/classifier/, so a handful resolve and the rest read as uncategorised, logged once as[categorisation] Dictionary file not found. Run it on the host: the repo dir is whatCOPY . .and the compose watch sync carry into the container. - The assistant is the one non-oRPC surface, and it still reads through oRPC.
src/assistant/holds the model client (provider.ts), the system prompt, the conversation store andhandler.ts— aRequest-to-Responsefunction thatapps/servermounts atPOST /ai/chat, because token streaming does not fit a unary procedure. Its tools do not query Prisma:tools.tscalls the very procedures the interface calls throughcreateRouterClient(appRouter, { context }), so an answer and a chart cannot drift, andprotectedProcedurestill guards every read. A new capability is a new tool over an existing procedure.
Adding a procedure
- Define the procedure in
src/routers/index.ts(or a new file undersrc/routers/re-exported from the index). - Choose
publicProcedureorprotectedProcedure. - Chain
.input(schema)for validation, then.handler(...). - The server and web client pick it up automatically — no wiring needed.