Middleware
Hono middleware for auth + access control. Located at apps/api/src/middleware/access.ts.
Available middleware
requireAuth
Parses the Authorization: Bearer <token> header, verifies the JWT, loads the user from DB, and sets c.set("user", authedUser) and c.set("userId", userId). 401 on any failure.
providersRoutes.use("*", requireAuth);
AuthedUser shape:
interface AuthedUser {
id: string;
email: string;
platformRole: PlatformRole;
orgPosition: OrgPosition;
departmentId: string | null;
}
requirePlatformRole(allowed)
Gates endpoints by platform role. Superadmin is in allowed by default (priority 1 in the resolver). Use for admin/engineer endpoints.
providersRoutes.post(
"/",
requirePlatformRole(["engineer", "superadmin"]),
zValidator("json", createSchema),
handler
);
userRoutes.post(
"/",
requirePlatformRole(["admin", "superadmin"]),
zValidator("json", createUserSchema),
handler
);
Returns 403 with {error: "insufficient_platform_role"} if mismatched.
requireCeoOrManager(paramKey = "deptId")
For endpoints where the CEO or the department's manager can act. Reads the department ID from the route param (default deptId). Looks up the manager relationship. Validates against the current user.
departmentRoutes.post(
"/:deptId/members",
requireAuth,
requireCeoOrManager(),
handler
);
requireProjectAccess(paramKey, minTier)
The workhorse for project-scoped endpoints. Calls resolveAccess(userId, projectId) and enforces a minimum tier.
projectRoutes.patch(
"/:id",
requireAuth,
requireProjectAccess("id", "edit"), // edit = can modify settings
zValidator("json", updateSchema),
handler
);
grantRoutes.post(
"/projects/:projectId/grants",
requireAuth,
requireProjectAccess("projectId", "full"), // full = can grant to others
handler
);
Sets c.set("accessResolution", { tier, source }) so the handler can inspect how the user got access.
requireBootstrapped
Returns 503 with {error: "not_bootstrapped"} if bootstrap_state.is_initialized = false. Use on every user-facing endpoint except /auth/* and /bootstrap/*.
requireNotBootstrapped
Inverse — 409 if already bootstrapped. Only POST /bootstrap/init uses it.
Middleware ordering
Order matters — middleware is applied in the sequence you chain it:
// WRONG — validator runs first, even without auth
routes.post("/", zValidator("json", schema), requireAuth, handler);
// RIGHT — auth first, then validator, then handler
routes.post("/", requireAuth, zValidator("json", schema), handler);
Rule of thumb:
requireAuth(or skip for public)requireBootstrapped(orrequireNotBootstrapped)requirePlatformRole(...)orrequireProjectAccess(...)orrequireCeoOrManager(...)zValidator(...)for body validation- Handler
Adding a new middleware
Example: rate-limiting per user.
// apps/api/src/middleware/rate-limit.ts
import { createMiddleware } from "hono/factory";
import type { AccessEnv } from "./access";
const hits = new Map<string, number[]>();
export function rateLimit(maxPerMinute: number) {
return createMiddleware<AccessEnv>(async (c, next) => {
const user = c.get("user");
if (!user) return next(); // unauth requests hit elsewhere
const now = Date.now();
const cutoff = now - 60_000;
const userHits = (hits.get(user.id) || []).filter(t => t > cutoff);
if (userHits.length >= maxPerMinute) {
return c.json({ error: "rate_limited" }, 429);
}
userHits.push(now);
hits.set(user.id, userHits);
await next();
});
}
Usage:
chatRoutes.post(
"/projects/:projectId",
requireAuth,
requireProjectAccess("projectId", "use"),
rateLimit(20), // 20 chats/minute per user
handler
);
Testing middleware
Integration-style — instantiate Hono, wire middleware, call with a fake JWT:
import { test, expect } from "bun:test";
import { Hono } from "hono";
import { requireAuth, requirePlatformRole } from "../middleware/access";
test("requirePlatformRole rejects wrong role", async () => {
const app = new Hono()
.use("*", requireAuth)
.use("*", requirePlatformRole(["admin"]))
.get("/", c => c.text("ok"));
const token = signTestJWT({ sub: "u1", platformRole: "none" });
const res = await app.request("/", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(403);
});
Full suite in apps/api/src/middleware/access.test.ts.