From 25c8bab7414d68bbbcd933378e3787ac491e66e2 Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 17:49:03 +0800 Subject: [PATCH] Add orgMemberMiddleware for org-scoped procedures - Add OrgInfo, OrgMembership, OrgMemberContext types to context.ts - Create org-member.ts middleware that: - Chains with authMiddleware - Takes input with org slug - Looks up org and verifies membership - Adds org and membership info to context - Export from middlewares/index.ts and procedures/base.ts Also simplify superuserMiddleware to use authMiddleware.concat() Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/context.ts | 31 +++++++ apps/api-server/src/middlewares/index.ts | 1 + apps/api-server/src/middlewares/org-member.ts | 87 +++++++++++++++++++ apps/api-server/src/middlewares/superuser.ts | 8 +- apps/api-server/src/procedures/base.ts | 9 +- 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 apps/api-server/src/middlewares/org-member.ts diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index 18c9c02..c05b0ba 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -115,3 +115,34 @@ export interface SuperuserContext extends AuthenticatedContext { /** User with superuser privileges */ user: SessionUser & { isSuperuser: true }; } + +/** + * Organization info in context + */ +export interface OrgInfo { + id: number; + slug: string; + displayName: string; + logoUrl: string | null; + createdAt: Date; +} + +/** + * User's membership in an org + */ +export interface OrgMembership { + id: number; + role: "owner" | "admin" | "member"; + createdAt: Date; +} + +/** + * Org member context for org-scoped procedures + * Requires user to be a member of the org + */ +export interface OrgMemberContext extends AuthenticatedContext { + /** The organization */ + org: OrgInfo; + /** User's membership in the org */ + membership: OrgMembership; +} diff --git a/apps/api-server/src/middlewares/index.ts b/apps/api-server/src/middlewares/index.ts index 50d6919..37e246a 100644 --- a/apps/api-server/src/middlewares/index.ts +++ b/apps/api-server/src/middlewares/index.ts @@ -4,5 +4,6 @@ export { authMiddleware } from "./auth.js"; export { loginRequestMiddleware } from "./login-request.js"; +export { orgMemberMiddleware } from "./org-member.js"; export { os } from "./os.js"; export { superuserMiddleware } from "./superuser.js"; diff --git a/apps/api-server/src/middlewares/org-member.ts b/apps/api-server/src/middlewares/org-member.ts new file mode 100644 index 0000000..d5b0b9c --- /dev/null +++ b/apps/api-server/src/middlewares/org-member.ts @@ -0,0 +1,87 @@ +/** + * Org member middleware - authenticates and verifies org membership + * + * This middleware chains authMiddleware first, then looks up the org + * and verifies the user is a member. Adds org and membership to context. + * + * Input must include `slug` (the org slug). + */ + +import type { + AuthenticatedContext, + OrgInfo, + OrgMembership, +} from "../context.js"; +import { ORPCError } from "@orpc/server"; +import { authMiddleware } from "./auth.js"; +import { os } from "./os.js"; + +interface OrgSlugInput { + slug: string; +} + +const orgMemberCheck = os.middleware( + async ( + { + context, + next, + }: { + context: AuthenticatedContext; + next: (opts: { + context: { org: OrgInfo; membership: OrgMembership }; + }) => Promise; + }, + input: OrgSlugInput, + ) => { + const { db } = context; + const { slug } = input; + + // Look up org by slug + const org = await db + .selectFrom("orgs") + .select(["id", "slug", "display_name", "logo_url", "created_at"]) + .where("slug", "=", slug) + .executeTakeFirst(); + + if (!org) { + throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); + } + + // Check user membership + const membership = await db + .selectFrom("org_members") + .select(["id", "role", "created_at"]) + .where("org_id", "=", org.id) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!membership) { + throw new ORPCError("FORBIDDEN", { + message: "You are not a member of this organization", + }); + } + + const orgInfo: OrgInfo = { + id: org.id, + slug: org.slug, + displayName: org.display_name, + logoUrl: org.logo_url, + createdAt: org.created_at, + }; + + const membershipInfo: OrgMembership = { + id: membership.id, + role: membership.role, + createdAt: membership.created_at, + }; + + return next({ + context: { + org: orgInfo, + membership: membershipInfo, + }, + }); + }, +); + +export const orgMemberMiddleware = authMiddleware.concat(orgMemberCheck); diff --git a/apps/api-server/src/middlewares/superuser.ts b/apps/api-server/src/middlewares/superuser.ts index c9f84d9..c900549 100644 --- a/apps/api-server/src/middlewares/superuser.ts +++ b/apps/api-server/src/middlewares/superuser.ts @@ -10,7 +10,13 @@ import { authMiddleware } from "./auth.js"; import { os } from "./os.js"; const superuserCheck = os.middleware( - async ({ context, next }: { context: AuthenticatedContext; next: () => Promise }) => { + async ({ + context, + next, + }: { + context: AuthenticatedContext; + next: () => Promise; + }) => { if (!context.user.isSuperuser) { throw new ORPCError("FORBIDDEN", { message: "Superuser access required", diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index 49d450e..d3cafd3 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -9,15 +9,22 @@ import type { APIContext, AuthenticatedContext, LoginRequestContext, + OrgMemberContext, } from "../context.js"; // Re-export middlewares and os from the middlewares folder export { authMiddleware, loginRequestMiddleware, + orgMemberMiddleware, os, superuserMiddleware, } from "../middlewares/index.js"; // Type exports for use in procedure files -export type { APIContext, AuthenticatedContext, LoginRequestContext }; +export type { + APIContext, + AuthenticatedContext, + LoginRequestContext, + OrgMemberContext, +};