feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
338
packages/api/src/middleware.ts
Normal file
338
packages/api/src/middleware.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { env } from "hono/adapter";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
import { getAllRolesAtOrAbove, hasAdminPermission } from "@turbostarter/auth";
|
||||
import { MemberRole } from "@turbostarter/auth";
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
import { creditTransaction, customer } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { makeZodI18nMap } from "@turbostarter/i18n";
|
||||
import {
|
||||
getLocaleFromRequest,
|
||||
getTranslation,
|
||||
} from "@turbostarter/i18n/server";
|
||||
import { HttpStatusCode, NodeEnv } from "@turbostarter/shared/constants";
|
||||
import { generateId, HttpException } from "@turbostarter/shared/utils";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { TFunction } from "@turbostarter/i18n";
|
||||
import type { Context, ValidationTargets } from "hono";
|
||||
import type { $ZodRawIssue, $ZodType } from "zod/v4/core";
|
||||
|
||||
type PermissionsInput = NonNullable<
|
||||
NonNullable<
|
||||
Parameters<typeof auth.api.hasPermission>[0]
|
||||
>["body"]["permissions"]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Reusable middleware that enforces users are logged in before running the
|
||||
* procedure
|
||||
*/
|
||||
export const enforceAuth = createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
const user = session?.user ?? null;
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(HttpStatusCode.UNAUTHORIZED, {
|
||||
code: "error.unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
c.set("user", user);
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable middleware that enforces that the authenticated user
|
||||
* has global admin permissions
|
||||
*/
|
||||
export const enforceAdmin = createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const user = c.var.user;
|
||||
|
||||
if (!hasAdminPermission(user)) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable middleware that enforces that the authenticated user
|
||||
* has the specified permissions in the user scope
|
||||
*/
|
||||
export const enforceUserPermission = ({
|
||||
permissions,
|
||||
}: {
|
||||
permissions: PermissionsInput;
|
||||
}) =>
|
||||
createMiddleware<{ Variables: { user: User } }>(async (c, next) => {
|
||||
const hasPermission = await auth.api.hasPermission({
|
||||
body: {
|
||||
permissions,
|
||||
},
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!hasPermission.success) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to enforce that the authenticated user has the required permissions
|
||||
* for a given organization before allowing access to the route handler.
|
||||
*/
|
||||
export const enforceOrganizationPermission = ({
|
||||
organizationId,
|
||||
permissions,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
permissions: PermissionsInput;
|
||||
}) =>
|
||||
createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const hasPermission = await auth.api.hasPermission({
|
||||
body: {
|
||||
organizationId,
|
||||
permissions,
|
||||
},
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!hasPermission.success) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to enforce that the authenticated user is at least a member
|
||||
* of the given organization before allowing access to the route handler.
|
||||
*/
|
||||
export const enforceMembership = ({
|
||||
organizationId,
|
||||
role = MemberRole.MEMBER,
|
||||
}: {
|
||||
organizationId: string;
|
||||
role?: MemberRole;
|
||||
}) =>
|
||||
createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const user = c.var.user;
|
||||
try {
|
||||
const { members } = await auth.api.listMembers({
|
||||
query: {
|
||||
organizationId,
|
||||
filterField: "userId",
|
||||
filterValue: user.id,
|
||||
filterOperator: "eq",
|
||||
},
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
const member = members.find((member) => member.userId === user.id);
|
||||
|
||||
if (!member || !getAllRolesAtOrAbove(role).includes(member.role)) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.forbidden",
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware for adding an articifial delay in development.
|
||||
*
|
||||
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
|
||||
* network latency that would occur in production but not in local development.
|
||||
*/
|
||||
export const delay = createMiddleware<{
|
||||
Bindings: {
|
||||
NODE_ENV: string;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
if (env(c).NODE_ENV === NodeEnv.DEVELOPMENT) {
|
||||
// artificial delay in dev 100-500ms
|
||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware for setting the language based on the cookie and accept-language header.
|
||||
*/
|
||||
export const localize = createMiddleware<{
|
||||
Variables: {
|
||||
locale: string;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const locale = getLocaleFromRequest(c.req.raw);
|
||||
c.set("locale", locale);
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware for validating the request input using Zod.
|
||||
*/
|
||||
export const validate = <
|
||||
T extends $ZodType,
|
||||
Target extends keyof ValidationTargets,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
) =>
|
||||
zValidator(
|
||||
target,
|
||||
schema,
|
||||
async (result, c: Context<{ Variables: { locale?: string } }, string>) => {
|
||||
if (!result.success) {
|
||||
const { t } = await getTranslation({
|
||||
locale: c.var.locale,
|
||||
});
|
||||
const error = result.error.issues[0];
|
||||
|
||||
if (!error) {
|
||||
throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
const { message, code } = makeZodI18nMap({ t: t as TFunction })(
|
||||
error as $ZodRawIssue,
|
||||
);
|
||||
|
||||
throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY, {
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiter middleware for AI endpoints.
|
||||
* Limits requests per user per time window.
|
||||
*/
|
||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export const rateLimiter = createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const user = c.var.user;
|
||||
const windowMs = 60 * 1000; // 1 minute
|
||||
const maxRequests = 30; // 30 requests per minute
|
||||
|
||||
const now = Date.now();
|
||||
const key = `rate-limit:${user.id}`;
|
||||
const record = rateLimitStore.get(key);
|
||||
|
||||
if (!record || record.resetAt < now) {
|
||||
rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.count >= maxRequests) {
|
||||
throw new HttpException(HttpStatusCode.TOO_MANY_REQUESTS, {
|
||||
code: "error.rateLimit",
|
||||
message: "Too many requests. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
record.count += 1;
|
||||
await next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to deduct credits from a user's account before processing AI requests.
|
||||
* Takes the amount of credits to deduct and optional feature name for audit logging.
|
||||
*/
|
||||
export const deductCredits = (amount: number, feature?: string) =>
|
||||
createMiddleware<{
|
||||
Variables: {
|
||||
user: User;
|
||||
};
|
||||
}>(async (c, next) => {
|
||||
const user = c.var.user;
|
||||
|
||||
// Find user's customer record
|
||||
const [customerRecord] = await db
|
||||
.select()
|
||||
.from(customer)
|
||||
.where(eq(customer.userId, user.id));
|
||||
|
||||
if (!customerRecord) {
|
||||
throw new HttpException(HttpStatusCode.FORBIDDEN, {
|
||||
code: "error.noCredits",
|
||||
message: "No subscription found. Please subscribe to use AI features.",
|
||||
});
|
||||
}
|
||||
|
||||
if (customerRecord.credits < amount) {
|
||||
throw new HttpException(HttpStatusCode.PAYMENT_REQUIRED, {
|
||||
code: "error.insufficientCredits",
|
||||
message: "Insufficient credits. Please add more credits to continue.",
|
||||
});
|
||||
}
|
||||
|
||||
const newBalance = customerRecord.credits - amount;
|
||||
|
||||
// Deduct credits and log transaction
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(customer)
|
||||
.set({
|
||||
credits: sql`${customer.credits} - ${amount}`,
|
||||
})
|
||||
.where(eq(customer.id, customerRecord.id));
|
||||
|
||||
await tx.insert(creditTransaction).values({
|
||||
id: generateId(),
|
||||
customerId: customerRecord.id,
|
||||
amount: -amount,
|
||||
type: "usage",
|
||||
reason: feature ?? "AI feature usage",
|
||||
balanceAfter: newBalance,
|
||||
metadata: JSON.stringify({
|
||||
endpoint: c.req.path,
|
||||
feature,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await next();
|
||||
});
|
||||
Reference in New Issue
Block a user