From 461e9953d5c1b5caf062cdb2c3638c82e9607e41 Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Tue, 2 Jun 2026 13:36:42 +0200 Subject: [PATCH 01/10] feat: cleanup some not great conventions --- flake.nix | 2 ++ package.json | 2 +- server/database/index.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index a234892..c3fd965 100644 --- a/flake.nix +++ b/flake.nix @@ -20,6 +20,8 @@ infisical railway vtsls + tailwindcss-language-server + vscode-langservers-extracted biome ]; buildInputs = [ ]; diff --git a/package.json b/package.json index 5c6cbee..744c9a5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "all": "bun run format && bun run lint && bun run typecheck && bun run build && bun run test:dev", "lint": "biome check . --write && bun run check-locale", "test": "vitest run", - "test:dev": "infisical run -- vitest watch", + "test:dev": "infisical run -- vitest run", "lint:check": "biome check . && bun run check-locale", "format": "biome format . --write", "format:check": "biome format .", diff --git a/server/database/index.ts b/server/database/index.ts index 643d3bf..7da2c3f 100644 --- a/server/database/index.ts +++ b/server/database/index.ts @@ -9,6 +9,8 @@ const migrationsFolder = resolve(process.cwd(), "server/database/migrations"); export const db = drizzle(env.DATABASE_URL); instrumentDrizzleClient(db); +export type Database = typeof db; + export async function runMigrations() { await migrate(db, { migrationsFolder }); } From 3cf2a79f28a3612afd3dd0fe997bc44d0438c873 Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Tue, 2 Jun 2026 13:37:51 +0200 Subject: [PATCH 02/10] feat: a bit nicer wiring and such --- server/features/demo/index.ts | 2 +- .../features/demo/routes/demo-trace.test.ts | 15 +++-- server/features/demo/routes/demo-trace.ts | 62 +++---------------- server/features/demo/schema.ts | 9 +++ server/features/demo/service.ts | 52 ++++++++++++++++ server/middleware/auth.middleware.test.ts | 6 +- server/middleware/auth.middleware.ts | 11 ++-- server/server.ts | 31 +++++----- web/app.tsx | 2 +- 9 files changed, 101 insertions(+), 89 deletions(-) create mode 100644 server/features/demo/schema.ts create mode 100644 server/features/demo/service.ts diff --git a/server/features/demo/index.ts b/server/features/demo/index.ts index c9b3d6c..997b9f1 100644 --- a/server/features/demo/index.ts +++ b/server/features/demo/index.ts @@ -1,4 +1,4 @@ import { createRouter } from "@/server/lib/router"; import { demoTraceRoute } from "./routes/demo-trace"; -export const demo = createRouter().route("/demo-trace", demoTraceRoute); +export const demo = createRouter().route("/trace", demoTraceRoute); diff --git a/server/features/demo/routes/demo-trace.test.ts b/server/features/demo/routes/demo-trace.test.ts index de62d4e..a289d74 100644 --- a/server/features/demo/routes/demo-trace.test.ts +++ b/server/features/demo/routes/demo-trace.test.ts @@ -1,18 +1,17 @@ import { expect, test } from "vitest"; import { createRouter } from "@/server/lib/router"; -import { authMiddleware } from "@/server/middleware/auth.middleware"; import { getTestApp } from "@/server/utils/testing/test-app"; import { TestUser1, testUsers } from "@/server/utils/testing/test-users"; import { demoTraceRoute } from "./demo-trace"; -const testApp = createRouter(); -testApp.use(authMiddleware); -testApp.route("/demo-trace", demoTraceRoute); +// The route declares its own `requireAuth` guard, so the test mounts it as-is — +// no need to re-wire auth here. getTestApp supplies the ambient test providers. +const testApp = createRouter().route("/trace", demoTraceRoute); test("returns default greeting without name param", async () => { const { app, dbClient } = await getTestApp(testApp, { testUsers }); - const res = await app.request("/demo-trace?skipDb=true&delay=0", { + const res = await app.request("/trace?skipDb=true&delay=0", { headers: { "X-Test-User-Id": TestUser1.id }, }); @@ -24,7 +23,7 @@ test("returns default greeting without name param", async () => { test("returns personalized greeting with name param", async () => { const { app, dbClient } = await getTestApp(testApp, { testUsers }); - const res = await app.request("/demo-trace?name=Tim&skipDb=true&delay=0", { + const res = await app.request("/trace?name=Tim&skipDb=true&delay=0", { headers: { "X-Test-User-Id": TestUser1.id }, }); @@ -36,7 +35,7 @@ test("returns personalized greeting with name param", async () => { test("returns 401 without authentication", async () => { const { app, dbClient } = await getTestApp(testApp, { testUsers }); - const res = await app.request("/demo-trace?skipDb=true&delay=0"); + const res = await app.request("/trace?skipDb=true&delay=0"); expect(res.status).toBe(401); dbClient.close(); @@ -45,7 +44,7 @@ test("returns 401 without authentication", async () => { test("returns 400 when delay exceeds maximum", async () => { const { app, dbClient } = await getTestApp(testApp, { testUsers }); - const res = await app.request("/demo-trace?delay=9999&skipDb=true", { + const res = await app.request("/trace?delay=9999&skipDb=true", { headers: { "X-Test-User-Id": TestUser1.id }, }); diff --git a/server/features/demo/routes/demo-trace.ts b/server/features/demo/routes/demo-trace.ts index 8e4df9d..cca5a54 100644 --- a/server/features/demo/routes/demo-trace.ts +++ b/server/features/demo/routes/demo-trace.ts @@ -1,62 +1,16 @@ import { zValidator } from "@hono/zod-validator"; -import { sql } from "drizzle-orm"; -import { z } from "zod"; -import { logger } from "@/server/lib/logger"; import { createRouter } from "@/server/lib/router"; -import { withSpan } from "@/server/lib/tracing"; - -const querySchema = z.object({ - name: z.string().min(1).optional(), - delay: z.coerce.number().min(0).max(5000).optional().default(500), - skipDb: z.coerce.boolean().optional().default(false), -}); +import { requireAuth } from "@/server/middleware/auth.middleware"; +import { demoTraceQuerySchema } from "../schema"; +import { runDemoTrace } from "../service"; +// Thin route: guard → validate → call service → respond. No business logic here. export const demoTraceRoute = createRouter().get( "/", - zValidator("query", querySchema), + requireAuth, + zValidator("query", demoTraceQuerySchema), async (c) => { - const { name, delay, skipDb } = c.req.valid("query"); - - logger.info({ name, delay, skipDb }, "Demo trace started"); - - if (!skipDb) { - // This DB query is auto-traced by @opentelemetry/instrumentation-pg - const db = c.get("db"); - await db.execute( - sql`SELECT 1 as "connection_test", NOW() as "current_time"`, - ); - logger.info({ query: "connection_test" }, "Database query completed"); - } - - // Only use withSpan when you need custom business logic grouping - await withSpan( - "demo.external_api_call", - { "demo.type": "simulation", "api.endpoint": "https://example.com" }, - async (span) => { - span.addEvent("api.request_started", { - "http.method": "GET", - "http.url": "https://example.com/api", - }); - - // Simulate external API latency - await new Promise((resolve) => setTimeout(resolve, delay)); - - span.addEvent("api.response_received", { - "http.status_code": 200, - "response.size_bytes": 1024, - }); - - logger.info({ latency: delay }, "External API call completed"); - }, - ); - - await fetch("https://jsonplaceholder.typicode.com/todos/1"); - - logger.info("Demo trace completed"); - - const greeting = name ? `Hello, ${name}!` : "Hello!"; - return c.json({ - message: greeting, - }); + const result = await runDemoTrace(c.get("db"), c.req.valid("query")); + return c.json(result); }, ); diff --git a/server/features/demo/schema.ts b/server/features/demo/schema.ts new file mode 100644 index 0000000..63bee9d --- /dev/null +++ b/server/features/demo/schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const demoTraceQuerySchema = z.object({ + name: z.string().min(1).optional(), + delay: z.coerce.number().min(0).max(5000).optional().default(500), + skipDb: z.coerce.boolean().optional().default(false), +}); + +export type DemoTraceInput = z.infer; diff --git a/server/features/demo/service.ts b/server/features/demo/service.ts new file mode 100644 index 0000000..70bc998 --- /dev/null +++ b/server/features/demo/service.ts @@ -0,0 +1,52 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "@/server/database"; +import { logger } from "@/server/lib/logger"; +import { withSpan } from "@/server/lib/tracing"; +import type { DemoTraceInput } from "./schema"; + +/** + * Business logic for the demo trace. Takes the db explicitly (not from context) + * so it stays unit-testable without HTTP and swappable in tests. + */ +export async function runDemoTrace( + database: Database, + { name, delay, skipDb }: DemoTraceInput, +) { + logger.info({ name, delay, skipDb }, "Demo trace started"); + + if (!skipDb) { + // This DB query is auto-traced by @opentelemetry/instrumentation-pg + await database.execute( + sql`SELECT 1 as "connection_test", NOW() as "current_time"`, + ); + logger.info({ query: "connection_test" }, "Database query completed"); + } + + // Only use withSpan when you need custom business logic grouping + await withSpan( + "demo.external_api_call", + { "demo.type": "simulation", "api.endpoint": "https://example.com" }, + async (span) => { + span.addEvent("api.request_started", { + "http.method": "GET", + "http.url": "https://example.com/api", + }); + + // Simulate external API latency + await new Promise((resolve) => setTimeout(resolve, delay)); + + span.addEvent("api.response_received", { + "http.status_code": 200, + "response.size_bytes": 1024, + }); + + logger.info({ latency: delay }, "External API call completed"); + }, + ); + + await fetch("https://jsonplaceholder.typicode.com/todos/1"); + + logger.info("Demo trace completed"); + + return { message: name ? `Hello, ${name}!` : "Hello!" }; +} diff --git a/server/middleware/auth.middleware.test.ts b/server/middleware/auth.middleware.test.ts index 317490f..566825e 100644 --- a/server/middleware/auth.middleware.test.ts +++ b/server/middleware/auth.middleware.test.ts @@ -1,12 +1,12 @@ import { expect, test } from "vitest"; import { createRouter } from "@/server/lib/router"; -import { authMiddleware } from "@/server/middleware/auth.middleware"; +import { requireAuth } from "@/server/middleware/auth.middleware"; import { getTestApp } from "@/server/utils/testing/test-app"; import { TestUser1, testUsers } from "@/server/utils/testing/test-users"; -// Create a dummy app with requireAuth middleware +// Create a dummy app with the requireAuth guard const testApp = createRouter(); -testApp.use(authMiddleware); +testApp.use(requireAuth); testApp.get("/", (c) => { return c.json({ message: "Hello, world!" }); }); diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 40b188a..70cf780 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -8,8 +8,8 @@ export type AuthMiddlewareVariables = { }; /** - * Session middleware - resolves the user from the session and populates context. - * Does not block requests without a session. + * Provider: resolves the user from the session and populates context. + * Does not block requests without a session — applied ambiently across the API. * Must be used after the OpenTelemetry middleware. */ export const sessionMiddleware: MiddlewareHandler = async (c, next) => { @@ -35,10 +35,11 @@ export const sessionMiddleware: MiddlewareHandler = async (c, next) => { }; /** - * Requires an authenticated user on context. Returns 401 if not set. - * Must be used after sessionMiddleware (or test auth middleware). + * Guard: requires an authenticated user on context. Returns 401 if not set. + * Apply per-route (not globally) on the routes that need protection. Relies on + * the ambient sessionMiddleware (or test auth middleware) having run first. */ -export const authMiddleware: MiddlewareHandler = async (c, next) => { +export const requireAuth: MiddlewareHandler = async (c, next) => { const user = c.get("user"); if (!user) { diff --git a/server/server.ts b/server/server.ts index 4f20025..5a29678 100644 --- a/server/server.ts +++ b/server/server.ts @@ -16,10 +16,7 @@ import { health } from "@/server/features/health"; import { otel } from "@/server/features/otel"; import { logger } from "@/server/lib/logger"; import { createRouter } from "@/server/lib/router"; -import { - authMiddleware, - sessionMiddleware, -} from "@/server/middleware/auth.middleware"; +import { sessionMiddleware } from "@/server/middleware/auth.middleware"; import { dbMiddleware } from "@/server/middleware/db.middleware"; // First, init Sentry to capture errors @@ -32,20 +29,20 @@ if (env.VITEST == null) { await runMigrations(); } -// Public API routes (no auth required) -const publicApi = new Hono() +// API routes — traced and exposed via RPC. +// +// Middleware layering (see server/CONVENTIONS.md): +// - Ambient providers run on every API route and make no access decision: +// they only populate context (optional user, db). +// - Access decisions are per-route guards (e.g. `requireAuth` on a route), +// never applied globally — so they can't leak onto sibling routes. +const api = createRouter() + // Ambient providers + .use(sessionMiddleware, dbMiddleware) + // Features — each owns a prefix; protection is declared per-route inside it .route("/auth", authFeature) - .route("/health", health); - -// Protected API routes (auth + db middleware) -const protectedApi = createRouter() - .use(sessionMiddleware) - .use(authMiddleware) - .use(dbMiddleware) - .route("/", demo); - -// API routes that will be traced and exposed via RPC -const api = new Hono().route("/", publicApi).route("/", protectedApi); + .route("/health", health) + .route("/demo", demo); const app = new Hono() // OTel proxy must be BEFORE tracing middleware (avoids recursive tracing) diff --git a/web/app.tsx b/web/app.tsx index 44d294f..1fdd6e1 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -12,7 +12,7 @@ export default function App() { const { t } = useTranslation("common"); const [name, setName] = useState("World"); - const demoTraceOptions = api["demo-trace"].$get.mutationOptions({}); + const demoTraceOptions = api.demo.trace.$get.mutationOptions({}); const demoTrace = useMutation({ ...demoTraceOptions, mutationFn: (args: Parameters[0]) => From 8ddf371653fe185afc899b8f342ea81ba44e651f Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 08:20:24 +0200 Subject: [PATCH 03/10] feat: sentry quick wins --- server/middleware/auth.middleware.ts | 3 +++ server/server.ts | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index 70cf780..bb81873 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -1,4 +1,5 @@ import { trace } from "@opentelemetry/api"; +import * as Sentry from "@sentry/bun"; import type { MiddlewareHandler } from "hono"; import { auth } from "@/server/lib/auth"; import { requestContext } from "@/server/lib/request-context"; @@ -22,6 +23,8 @@ export const sessionMiddleware: MiddlewareHandler = async (c, next) => { span?.setAttribute("user.id", session.user.id); span?.setAttribute("user.email", session.user.email); + Sentry.setUser({ id: session.user.id, email: session.user.email }); + await requestContext.run( { userId: session.user.id, userEmail: session.user.email }, async () => { diff --git a/server/server.ts b/server/server.ts index 5a29678..fedb1af 100644 --- a/server/server.ts +++ b/server/server.ts @@ -62,18 +62,29 @@ app.notFound((c) => { }); app.onError((err, c) => { + const span = trace.getActiveSpan(); + if (err instanceof HTTPException) { const level = err.status >= 500 ? "error" : "warn"; logger[level]({ status: err.status }, err.message); - trace.getActiveSpan()?.setAttribute("error.reason", err.message); + span?.setAttribute("error.reason", err.message); + // Library-thrown 5xx is a real incident; 4xx is expected and would be noise. + if (err.status >= 500) Sentry.captureException(err); return err.getResponse(); } + // Unexpected: log, record on the trace, report to Sentry, return a generic 500. + // onError catches the throw and returns, so Sentry's default fetch-boundary + // capture never fires — we must report it explicitly here. logger.error({ err }, "Unhandled error"); - const span = trace.getActiveSpan(); span?.recordException(err); span?.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); - return c.json({ error: "Internal server error" }, 500); + Sentry.captureException(err); + // Return the trace id so a user-reported 500 can be matched to its trace. + return c.json( + { error: "Internal server error", traceId: span?.spanContext().traceId }, + 500, + ); }); // Static file serving and SPA fallback From e70b908de09522cec091233e4cdb31d568ce5ac0 Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 09:13:03 +0200 Subject: [PATCH 04/10] refactor: better database type handling --- server/database/index.ts | 9 +++++++-- server/middleware/db.middleware.ts | 4 ++-- server/utils/testing/test-auth.middleware.ts | 5 ++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/database/index.ts b/server/database/index.ts index 7da2c3f..9a1d695 100644 --- a/server/database/index.ts +++ b/server/database/index.ts @@ -2,14 +2,19 @@ import { resolve } from "node:path"; import { instrumentDrizzleClient } from "@kubiks/otel-drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; import env from "@/env"; +import * as schema from "./schema"; const migrationsFolder = resolve(process.cwd(), "server/database/migrations"); -export const db = drizzle(env.DATABASE_URL); +export const db = drizzle(env.DATABASE_URL, { schema }); instrumentDrizzleClient(db); -export type Database = typeof db; +// Cross-driver, transaction-compatible handle: the prod pool, a pglite test db, +// and a transaction all satisfy it. Services type their `db` param as this. +// (The base PgDatabase intentionally hides driver internals like `.$client`.) +export type Database = PgDatabase; export async function runMigrations() { await migrate(db, { migrationsFolder }); diff --git a/server/middleware/db.middleware.ts b/server/middleware/db.middleware.ts index e4339fa..ff64ed7 100644 --- a/server/middleware/db.middleware.ts +++ b/server/middleware/db.middleware.ts @@ -1,8 +1,8 @@ import type { MiddlewareHandler } from "hono/types"; -import { db } from "@/server/database"; +import { type Database, db } from "@/server/database"; export type DbMiddlewareVariables = { - db: typeof db; + db: Database; }; /** diff --git a/server/utils/testing/test-auth.middleware.ts b/server/utils/testing/test-auth.middleware.ts index 1a0ca88..2fc983b 100644 --- a/server/utils/testing/test-auth.middleware.ts +++ b/server/utils/testing/test-auth.middleware.ts @@ -1,6 +1,5 @@ -import type { PgliteDatabase } from "drizzle-orm/pglite/driver"; import type { MiddlewareHandler } from "hono"; -import type * as schema from "@/server/database/schema"; +import type { Database } from "@/server/database"; import { logger } from "@/server/lib/logger"; const testAuthMiddleware: MiddlewareHandler = async (c, next) => { @@ -13,7 +12,7 @@ const testAuthMiddleware: MiddlewareHandler = async (c, next) => { return; } - const db: PgliteDatabase = c.get("db"); + const db: Database = c.get("db"); // Select the user from the database const user = await db.query.user.findFirst({ From 1951fb3da5358a5bfc0cdbba7df37524d36a7177 Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 10:29:28 +0200 Subject: [PATCH 05/10] feat: error helper --- server/lib/http.ts | 17 +++++++++++++++++ server/middleware/auth.middleware.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 server/lib/http.ts diff --git a/server/lib/http.ts b/server/lib/http.ts new file mode 100644 index 0000000..a082bd8 --- /dev/null +++ b/server/lib/http.ts @@ -0,0 +1,17 @@ +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +/** + * The one sanctioned way to return an error response. Codifies the error shape + * (`{ error: string }`) so it can't drift across handlers, and stays generic + * over the status code so the returned error survives into the RPC client type + * (a non-generic helper would collapse the route's response type to `unknown`). + * + * Use this on the *return* path — expected errors, including guards. The + * unexpected path throws instead and is handled centrally by `onError`. + */ +export const apiError = ( + c: Context, + status: S, + message: string, +) => c.json({ error: message }, status); diff --git a/server/middleware/auth.middleware.ts b/server/middleware/auth.middleware.ts index bb81873..2508a0f 100644 --- a/server/middleware/auth.middleware.ts +++ b/server/middleware/auth.middleware.ts @@ -2,6 +2,7 @@ import { trace } from "@opentelemetry/api"; import * as Sentry from "@sentry/bun"; import type { MiddlewareHandler } from "hono"; import { auth } from "@/server/lib/auth"; +import { apiError } from "@/server/lib/http"; import { requestContext } from "@/server/lib/request-context"; export type AuthMiddlewareVariables = { @@ -46,7 +47,7 @@ export const requireAuth: MiddlewareHandler = async (c, next) => { const user = c.get("user"); if (!user) { - return c.json({ error: "Unauthorized" }, 401); + return apiError(c, 401, "Unauthorized"); } await next(); From 75547201df1b0dbe2209256e6fdd2bcbad883972 Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 12:16:46 +0200 Subject: [PATCH 06/10] fix: otel/hono handling better --- server/features/otel/index.ts | 8 +++--- server/features/otel/routes/post-traces.ts | 29 +++++++++++++--------- server/server.ts | 3 ++- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/server/features/otel/index.ts b/server/features/otel/index.ts index c4524f4..e65d873 100644 --- a/server/features/otel/index.ts +++ b/server/features/otel/index.ts @@ -1,6 +1,6 @@ -import { createRouter } from "@/server/lib/router"; +import { Hono } from "hono"; import { postTracesRoute } from "./routes/post-traces"; -export const otel = createRouter() - // Add feature-specific middleware here if needed - .route("/v1/traces", postTracesRoute); +// Plain Hono (not createRouter): this proxy needs no AppEnv providers, and it's +// mounted outside them — typing it AppEnv would falsely promise db/user. +export const otel = new Hono().route("/v1/traces", postTracesRoute); diff --git a/server/features/otel/routes/post-traces.ts b/server/features/otel/routes/post-traces.ts index e5a503a..5fc26ce 100644 --- a/server/features/otel/routes/post-traces.ts +++ b/server/features/otel/routes/post-traces.ts @@ -1,18 +1,23 @@ -import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { Hono } from "hono"; import env from "@/env"; +import { apiError } from "@/server/lib/http"; import { logger } from "@/server/lib/logger"; -import { createRouter } from "@/server/lib/router"; /** * OpenTelemetry trace proxy for frontend. * Forwards browser traces to Axiom, adding auth headers server-side. + * + * Unauthenticated telemetry proxy — no AppEnv (db/user) needed. */ -export const postTracesRoute = createRouter().post("/", async (c) => { +export const postTracesRoute = new Hono().post("/", async (c) => { if (!env.AXIOM_TOKEN || !env.AXIOM_DATASET) { logger.error("Axiom not configured for trace proxy"); - return c.json({ error: "Axiom not configured" }, 503); + return apiError(c, 503, "Axiom not configured"); } + // Deliberately translate upstream/network failures into a logged response + // rather than letting them bubble to onError — telemetry-ingestion blips + // shouldn't page via Sentry. try { const body = await c.req.arrayBuffer(); const contentType = @@ -25,21 +30,21 @@ export const postTracesRoute = createRouter().post("/", async (c) => { "x-axiom-dataset": env.AXIOM_DATASET, "Content-Type": contentType, }, - body: body, + body, }); if (!response.ok) { const text = await response.text(); - logger.error({ upstreamError: text }, "Axiom error:"); - return c.json( - { error: "Upstream error", details: text }, - response.status as ContentfulStatusCode, + logger.error( + { status: response.status, upstreamError: text }, + "Axiom error", ); + return apiError(c, 502, "Upstream error"); } return c.json({ success: true }); - } catch (e) { - logger.error({ error: e }, "Proxy error:"); - return c.json({ error: "Proxy error" }, 500); + } catch (error) { + logger.error({ error }, "Trace proxy error"); + return apiError(c, 502, "Trace proxy error"); } }); diff --git a/server/server.ts b/server/server.ts index fedb1af..4e279c5 100644 --- a/server/server.ts +++ b/server/server.ts @@ -14,6 +14,7 @@ import { authFeature } from "@/server/features/auth"; import { demo } from "@/server/features/demo"; import { health } from "@/server/features/health"; import { otel } from "@/server/features/otel"; +import { apiError } from "@/server/lib/http"; import { logger } from "@/server/lib/logger"; import { createRouter } from "@/server/lib/router"; import { sessionMiddleware } from "@/server/middleware/auth.middleware"; @@ -58,7 +59,7 @@ const app = new Hono() app.notFound((c) => { logger.warn("Route not found"); - return c.json({ error: "Not found" }, 404); + return apiError(c, 404, "Not found"); }); app.onError((err, c) => { From b01c04a83ac99c267d62540c1a46db141a7f30eb Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 13:04:38 +0200 Subject: [PATCH 07/10] feat: docs --- server/README.md | 75 +++++++++++++++++++ server/features/demo/routes/demo-trace.ts | 2 +- server/features/demo/service.ts | 2 +- .../demo/{schema.ts => validators.ts} | 0 server/server.ts | 2 + 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 server/README.md rename server/features/demo/{schema.ts => validators.ts} (100%) diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8ae704f --- /dev/null +++ b/server/README.md @@ -0,0 +1,75 @@ +# Backend conventions + +1. Middleware that "enriches" context is run globally, e.g. for our user info, + and currently db (this is slightly controversial, the more general approach is + to simply import the db in the handler, but for us it buys us testability via + pglite and enables any potential RLS future). These providers run via + `api.use(...)` in `server.ts`. +1. Access _decisions_ (auth, subscription, ...) are the opposite: never global, + always per-route guards — see `requireAuth` (`middleware/auth.middleware.ts`) + applied directly on the route in `demo/routes/demo-trace.ts`. A global guard + would gate every route mounted after it; keeping it per-route is what avoids + that leak. +1. We develop this backend "feature first", meaning we split by features and one + feature owns its route prefix (e.g. a `chat` feature, owns the `/chat` prefix). +1. When accessing user owned resources, e.g. a chat, remember that we first need + to check if the resource exists and if the user has access to it, which should + both return a 404, to avoid leaking information (a 403 would reveal the + resource exists). We could potentially log the cases tho, but not right now. +1. Observability is handled via OTEL and Axiom. The later provides an MCP to build + dashboards, which usually results in a better result than the default Axiom one. +1. Always have the unhappy path in mind. + +## Error handling + +We return **expected** errors from the handler via `apiError`, e.g. +`return apiError(c, 404, "Not found")` — and yes, even an anticipated 5xx counts +(a failing upstream is still an expected outcome). **Unexpected** errors we let +throw, to be handled by hono's own `onError` in [the server](./server.ts). + +I know this smells a bit like Go, but there is a good reason: a returned error +from a handler shows up as part of the exported RPC type for the frontend +(only handler returns, though — guard/middleware returns don't), and I just like +errors as values. + +So in practice, you should almost never do try/catch in handlers to return +500\. The only reason to use try/catch is if you get a downstream error (e.g. +from Postgres) and you want to properly log/type it. + +## When features get large + +For small features, the guiding principle is to just put everything inside +their handlers. One lean file that shouldn't be more than 200-300 lines long +that just holds all the logic. + +Once a feature grows, split out only the parts that earn it: + +- `routes/` — the thin handlers (guard → validate → call service → respond). +- `validators.ts` — zod schemas. +- `service.ts` — business logic; takes `db` as an argument. +- `queries.ts` — db access, pulled out of the service once queries pile up or + get shared. +- `lib/` — feature-local helpers. If a helper is useful to *other* features, it + graduates to `server/lib` instead. + +The flow is handler → service → queries, with `db` passed down (never grabbed +from context inside a service). Add each file only when the feature grows or you +start sharing — a small feature stays one handler. + +## API paths + +Paths concatenate down the mount tree — each level adds one segment: + +1. **`server.ts` owns the prefixes** — `/api` plus each feature's prefix + (`/demo`). The one place to read the whole URL map. +1. **A feature's `index.ts` owns its in-feature paths** (`/`, `/:id`), relative + to itself — it never repeats its own prefix. +1. **Route files are relative to the route** — usually just `/`. + +```ts +api.route("/demo", demo); // server.ts -> /api/demo +createRouter().route("/trace", demoTraceRoute); // demo/index.ts -> /demo/trace +createRouter().get("/", requireAuth, handler); // demo-trace.ts -> /trace +``` + +So a feature is prefix-agnostic — moving it is a one-line change in `server.ts`. diff --git a/server/features/demo/routes/demo-trace.ts b/server/features/demo/routes/demo-trace.ts index cca5a54..f62b1e8 100644 --- a/server/features/demo/routes/demo-trace.ts +++ b/server/features/demo/routes/demo-trace.ts @@ -1,7 +1,7 @@ import { zValidator } from "@hono/zod-validator"; import { createRouter } from "@/server/lib/router"; import { requireAuth } from "@/server/middleware/auth.middleware"; -import { demoTraceQuerySchema } from "../schema"; +import { demoTraceQuerySchema } from "../validators"; import { runDemoTrace } from "../service"; // Thin route: guard → validate → call service → respond. No business logic here. diff --git a/server/features/demo/service.ts b/server/features/demo/service.ts index 70bc998..d55f585 100644 --- a/server/features/demo/service.ts +++ b/server/features/demo/service.ts @@ -2,7 +2,7 @@ import { sql } from "drizzle-orm"; import type { Database } from "@/server/database"; import { logger } from "@/server/lib/logger"; import { withSpan } from "@/server/lib/tracing"; -import type { DemoTraceInput } from "./schema"; +import type { DemoTraceInput } from "./validators"; /** * Business logic for the demo trace. Takes the db explicitly (not from context) diff --git a/server/features/demo/schema.ts b/server/features/demo/validators.ts similarity index 100% rename from server/features/demo/schema.ts rename to server/features/demo/validators.ts diff --git a/server/server.ts b/server/server.ts index 4e279c5..1203ec4 100644 --- a/server/server.ts +++ b/server/server.ts @@ -27,6 +27,8 @@ Sentry.init({ }); if (env.VITEST == null) { + // NOTE: if we ever scale the backend beyond one instance, this becomes a + // race condition. await runMigrations(); } From 009eb7078a3c06936cd8344ae093bad731e2253f Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Wed, 3 Jun 2026 13:06:56 +0200 Subject: [PATCH 08/10] chore: formatting --- server/features/demo/routes/demo-trace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/features/demo/routes/demo-trace.ts b/server/features/demo/routes/demo-trace.ts index f62b1e8..b7d6e30 100644 --- a/server/features/demo/routes/demo-trace.ts +++ b/server/features/demo/routes/demo-trace.ts @@ -1,8 +1,8 @@ import { zValidator } from "@hono/zod-validator"; import { createRouter } from "@/server/lib/router"; import { requireAuth } from "@/server/middleware/auth.middleware"; -import { demoTraceQuerySchema } from "../validators"; import { runDemoTrace } from "../service"; +import { demoTraceQuerySchema } from "../validators"; // Thin route: guard → validate → call service → respond. No business logic here. export const demoTraceRoute = createRouter().get( From 5937259bc5131b2a3d060f559e4650adf7a82b2d Mon Sep 17 00:00:00 2001 From: Tim Kalan Date: Thu, 4 Jun 2026 08:59:12 +0200 Subject: [PATCH 09/10] feat: cache type of api (and fix icons) --- README.md | 4 +++- web/app.tsx | 6 +++--- web/lib/api.ts | 8 +++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fa8367a..85cd8bb 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ bun dev ``` This command starts: + - PostgreSQL database (via Docker) - Vite dev server with HMR - Drizzle Studio for database management @@ -80,12 +81,14 @@ This command starts: | `bun run import:staging` | Import data from staging | **Workflow:** + - **Development:** Use `db:push` for fast iteration (no migration files) - **Staging/Production:** Use `db:generate` + `db:migrate` (tracked, reviewable changes) Re-run `db:regenerate-auth` when adding Better Auth plugins or upgrading. **Schema files:** + - `server/database/schema/auth.ts` - Auto-generated (safe to overwrite) - `server/database/schema/app.ts` - Your custom tables (never overwritten) @@ -151,5 +154,4 @@ GitHub Actions runs on every push and PR to `master`: ## TODO -- [ ] DB sync from staging (implement `scripts/import-staging.sh`) - [ ] Sentry frontend integration diff --git a/web/app.tsx b/web/app.tsx index 1fdd6e1..af2688e 100644 --- a/web/app.tsx +++ b/web/app.tsx @@ -29,19 +29,19 @@ export default function App() {
React Logo

+

Hono Logo

+

Tailwind CSS Logo diff --git a/web/lib/api.ts b/web/lib/api.ts index 35c5541..ee8c855 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -2,5 +2,11 @@ import { hc } from "hono/client"; import { hcQuery } from "hono-rpc-query"; import type { AppType } from "@/server/server"; -const client = hc("/api"); +// Precompile the RPC client type so tsc instantiates hc once, instead +// of tsserver re-instantiating it on every use. +// comes from: https://hono.dev/docs/guides/rpc +// If perf is still an issue, consider splitting the client per feature +type Client = ReturnType>; + +const client: Client = hc("/api"); export const api = hcQuery(client); From ff76b418f77856474195497538b631ec4a86b90c Mon Sep 17 00:00:00 2001 From: drobilc Date: Wed, 10 Jun 2026 13:40:55 +0200 Subject: [PATCH 10/10] chore: fix typo Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/README.md b/server/README.md index 8ae704f..3851c81 100644 --- a/server/README.md +++ b/server/README.md @@ -16,7 +16,7 @@ to check if the resource exists and if the user has access to it, which should both return a 404, to avoid leaking information (a 403 would reveal the resource exists). We could potentially log the cases tho, but not right now. -1. Observability is handled via OTEL and Axiom. The later provides an MCP to build +1. Observability is handled via OTEL and Axiom. The latter provides an MCP to build dashboards, which usually results in a better result than the default Axiom one. 1. Always have the unhappy path in mind.