From 9cf95095c3d9c9a24e25b5a015be3643f4bf2434 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:50:29 +0800 Subject: [PATCH] Implement Workstream J: Org procedures (backend) Add complete organization management procedures: - orgs.list, create, get, update, delete, leave - orgs.members.list, updateRole, remove - orgs.invites.list, create, cancel, accept - orgs.sites.list Key features: - Role-based access control (owner > admin > member) - Transaction-protected owner count checks to prevent race conditions - Privilege escalation prevention (only owners can invite owners) - Graceful constraint violation handling with friendly error messages - Email sending for org invitations Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/procedures/orgs/basic.ts | 100 ++++++++ .../api-server/src/procedures/orgs/helpers.ts | 124 ++++++++++ apps/api-server/src/procedures/orgs/index.ts | 14 ++ .../api-server/src/procedures/orgs/invites.ts | 219 ++++++++++++++++++ .../src/procedures/orgs/management.ts | 95 ++++++++ .../api-server/src/procedures/orgs/members.ts | 158 +++++++++++++ apps/api-server/src/procedures/orgs/sites.ts | 34 +++ apps/api-server/src/router.ts | 94 ++------ 8 files changed, 765 insertions(+), 73 deletions(-) create mode 100644 apps/api-server/src/procedures/orgs/basic.ts create mode 100644 apps/api-server/src/procedures/orgs/helpers.ts create mode 100644 apps/api-server/src/procedures/orgs/index.ts create mode 100644 apps/api-server/src/procedures/orgs/invites.ts create mode 100644 apps/api-server/src/procedures/orgs/management.ts create mode 100644 apps/api-server/src/procedures/orgs/members.ts create mode 100644 apps/api-server/src/procedures/orgs/sites.ts diff --git a/apps/api-server/src/procedures/orgs/basic.ts b/apps/api-server/src/procedures/orgs/basic.ts new file mode 100644 index 0000000..f99f919 --- /dev/null +++ b/apps/api-server/src/procedures/orgs/basic.ts @@ -0,0 +1,100 @@ +/** + * Basic org procedures - list, create, get + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; +import { getMembership, lookupOrgBySlug } from "./helpers.js"; + +/** + * List all orgs the current user is a member of + */ +export const orgsList = os.orgs.list + .use(authMiddleware) + .handler(async ({ context }) => { + const orgs = await context.db + .selectFrom("org_members") + .innerJoin("orgs", "orgs.id", "org_members.org_id") + .where("org_members.user_id", "=", context.user.id) + .select([ + "orgs.id", + "orgs.slug", + "orgs.display_name", + "orgs.logo_url", + "orgs.created_at", + ]) + .orderBy("orgs.created_at", "desc") + .execute(); + + return orgs.map((o) => ({ + id: o.id, + slug: o.slug, + displayName: o.display_name, + logoUrl: o.logo_url, + createdAt: o.created_at, + })); + }); + +/** + * Create a new org + * The creating user becomes the owner + */ +export const orgsCreate = os.orgs.create + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, displayName } = input; + + try { + await context.db.transaction().execute(async (trx) => { + // Create the org + const org = await trx + .insertInto("orgs") + .values({ + slug, + display_name: displayName, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + // Add the creating user as owner + await trx + .insertInto("org_members") + .values({ + org_id: org.id, + user_id: context.user.id, + role: "owner", + }) + .execute(); + }); + + return { slug }; + } catch (error) { + // Handle unique constraint violation on slug + if (error instanceof Error && error.message.includes("orgs_slug_key")) { + throw new ORPCError("CONFLICT", { message: "Slug already in use" }); + } + throw error; + } + }); + +/** + * Get a single org by slug + * Requires membership + */ +export const orgsGet = os.orgs.get + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and verify membership + const org = await lookupOrgBySlug(context.db, slug); + await getMembership(context.db, org.id, context.user.id); + + return { + id: org.id, + slug: org.slug, + displayName: org.displayName, + logoUrl: org.logoUrl, + createdAt: org.createdAt, + }; + }); diff --git a/apps/api-server/src/procedures/orgs/helpers.ts b/apps/api-server/src/procedures/orgs/helpers.ts new file mode 100644 index 0000000..d791907 --- /dev/null +++ b/apps/api-server/src/procedures/orgs/helpers.ts @@ -0,0 +1,124 @@ +/** + * Helper functions for org procedures + * Provides org lookup, membership verification, and role checks + */ + +import type { DB, OrgRole } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; +import { ORPCError } from "@orpc/server"; + +// ===== Types ===== + +/** Org info returned from lookup */ +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: OrgRole; + createdAt: Date; +} + +// ===== Role Hierarchy ===== + +const ROLE_LEVELS: Record = { + owner: 3, + admin: 2, + member: 1, +}; + +// ===== Helper Functions ===== + +/** + * Look up an org by slug + * @throws NOT_FOUND if org doesn't exist + */ +export async function lookupOrgBySlug( + db: Kysely, + slug: string, +): Promise { + 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" }); + } + + return { + id: org.id, + slug: org.slug, + displayName: org.display_name, + logoUrl: org.logo_url, + createdAt: org.created_at, + }; +} + +/** + * Get a user's membership in an org + * @throws FORBIDDEN if user is not a member + */ +export async function getMembership( + db: Kysely, + orgId: number, + userId: number, +): Promise { + const membership = await db + .selectFrom("org_members") + .select(["id", "role", "created_at"]) + .where("org_id", "=", orgId) + .where("user_id", "=", userId) + .executeTakeFirst(); + + if (!membership) { + throw new ORPCError("FORBIDDEN", { + message: "You are not a member of this organization", + }); + } + + return { + id: membership.id, + role: membership.role, + createdAt: membership.created_at, + }; +} + +/** + * Verify user has the required role or higher + * Role hierarchy: owner > admin > member + * @throws FORBIDDEN if user lacks required role + */ +export function requireRole( + membership: OrgMembership, + requiredRole: OrgRole, +): void { + if (ROLE_LEVELS[membership.role] < ROLE_LEVELS[requiredRole]) { + throw new ORPCError("FORBIDDEN", { message: "Insufficient permissions" }); + } +} + +/** + * Count the number of owners in an org + * Used to prevent removing/demoting the last owner + */ +export async function countOwners( + db: Kysely, + orgId: number, +): Promise { + const result = await db + .selectFrom("org_members") + .select((eb) => eb.fn.countAll().as("count")) + .where("org_id", "=", orgId) + .where("role", "=", "owner") + .executeTakeFirstOrThrow(); + + return Number(result.count); +} diff --git a/apps/api-server/src/procedures/orgs/index.ts b/apps/api-server/src/procedures/orgs/index.ts new file mode 100644 index 0000000..681ebbe --- /dev/null +++ b/apps/api-server/src/procedures/orgs/index.ts @@ -0,0 +1,14 @@ +/** + * Org procedures - organization management + */ + +export { orgsCreate, orgsGet, orgsList } from "./basic.js"; +export { + invitesAccept, + invitesCancel, + invitesCreate, + invitesList, +} from "./invites.js"; +export { orgsDelete, orgsLeave, orgsUpdate } from "./management.js"; +export { membersList, membersRemove, membersUpdateRole } from "./members.js"; +export { sitesList } from "./sites.js"; diff --git a/apps/api-server/src/procedures/orgs/invites.ts b/apps/api-server/src/procedures/orgs/invites.ts new file mode 100644 index 0000000..43e4729 --- /dev/null +++ b/apps/api-server/src/procedures/orgs/invites.ts @@ -0,0 +1,219 @@ +/** + * Org invite procedures - list, create, cancel, accept + */ + +import { ORPCError } from "@orpc/server"; +import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js"; +import { generateExpiry, generateSecureToken } from "../../utils/crypto.js"; +import { sendOrgInviteEmail } from "../../utils/email.js"; +import { authMiddleware, os } from "../base.js"; +import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js"; + +/** + * List pending invites for an org + * Requires admin or owner role + */ +export const invitesList = os.orgs.invites.list + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and verify admin+ role + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "admin"); + + // Get non-expired invites with inviter details + const invites = await context.db + .selectFrom("org_invites") + .innerJoin("users", "users.id", "org_invites.invited_by") + .where("org_invites.org_id", "=", org.id) + .where("org_invites.expires_at", ">", new Date()) + .select([ + "org_invites.id", + "org_invites.email", + "org_invites.role", + "org_invites.created_at", + "org_invites.expires_at", + "users.display_name as inviter_name", + "users.email as inviter_email", + ]) + .orderBy("org_invites.created_at", "desc") + .execute(); + + return invites.map((i) => ({ + id: i.id, + email: i.email, + role: i.role, + invitedBy: i.inviter_name ?? i.inviter_email, + createdAt: i.created_at, + expiresAt: i.expires_at, + })); + }); + +/** + * Create an invite for a new member + * Requires admin or owner role + * Only owners can invite new owners (privilege escalation prevention) + */ +export const invitesCreate = os.orgs.invites.create + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, email: rawEmail, role } = input; + const email = rawEmail.toLowerCase(); + + // Lookup org and verify admin+ role + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "admin"); + + // Only owners can invite new owners (prevent privilege escalation) + if (role === "owner") { + requireRole(membership, "owner"); + } + + // Check if user is already a member (by email) + const existingMember = await context.db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("org_members.org_id", "=", org.id) + .where("users.email", "=", email) + .select(["org_members.id"]) + .executeTakeFirst(); + + if (existingMember) { + throw new ORPCError("CONFLICT", { + message: "This user is already a member of the organization", + }); + } + + // Generate invite token and expiry + const token = generateSecureToken(); + const expiresAt = generateExpiry(ORG_INVITE_EXPIRY_DAYS * 24 * 60 * 60); + + try { + // Create the invite + await context.db + .insertInto("org_invites") + .values({ + org_id: org.id, + email, + role, + invited_by: context.user.id, + token, + expires_at: expiresAt, + }) + .execute(); + } catch (error) { + // Handle unique constraint violation (race condition: another invite was created) + if ( + error instanceof Error && + error.message.includes("org_invites_org_id_email_key") + ) { + throw new ORPCError("CONFLICT", { + message: "An invitation is already pending for this email", + }); + } + throw error; + } + + // Send invitation email + const inviterName = context.user.displayName ?? context.user.email; + await sendOrgInviteEmail(email, token, org.displayName, inviterName, role); + }); + +/** + * Cancel a pending invite + * Requires admin or owner role + */ +export const invitesCancel = os.orgs.invites.cancel + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, inviteId } = input; + + // Lookup org and verify admin+ role + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "admin"); + + // Delete the invite + const result = await context.db + .deleteFrom("org_invites") + .where("id", "=", inviteId) + .where("org_id", "=", org.id) + .executeTakeFirst(); + + if (!result.numDeletedRows || result.numDeletedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Invitation not found" }); + } + }); + +/** + * Accept an invitation + * Token-based lookup, requires auth but no org membership + * Handles race condition if user is already a member + */ +export const invitesAccept = os.orgs.invites.accept + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { token } = input; + + // Find the invite by token (must not be expired) + const invite = await context.db + .selectFrom("org_invites") + .where("token", "=", token) + .where("expires_at", ">", new Date()) + .select(["id", "org_id", "email", "role"]) + .executeTakeFirst(); + + if (!invite) { + throw new ORPCError("NOT_FOUND", { + message: "Invalid or expired invitation", + }); + } + + // Verify the authenticated user's email matches the invite email + if (context.user.email.toLowerCase() !== invite.email.toLowerCase()) { + throw new ORPCError("FORBIDDEN", { + message: "This invitation was sent to a different email address", + }); + } + + try { + // Accept the invite in a transaction + await context.db.transaction().execute(async (trx) => { + // Add user as a member + await trx + .insertInto("org_members") + .values({ + org_id: invite.org_id, + user_id: context.user.id, + role: invite.role, + }) + .execute(); + + // Delete the invite + await trx + .deleteFrom("org_invites") + .where("id", "=", invite.id) + .execute(); + }); + } catch (error) { + // Handle unique constraint violation (user is already a member) + if ( + error instanceof Error && + error.message.includes("org_members_org_id_user_id_key") + ) { + // Clean up the invite since user is already a member + await context.db + .deleteFrom("org_invites") + .where("id", "=", invite.id) + .execute(); + + throw new ORPCError("CONFLICT", { + message: "You are already a member of this organization", + }); + } + throw error; + } + }); diff --git a/apps/api-server/src/procedures/orgs/management.ts b/apps/api-server/src/procedures/orgs/management.ts new file mode 100644 index 0000000..57ff4cf --- /dev/null +++ b/apps/api-server/src/procedures/orgs/management.ts @@ -0,0 +1,95 @@ +/** + * Org management procedures - update, delete, leave + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; +import { + countOwners, + getMembership, + lookupOrgBySlug, + requireRole, +} from "./helpers.js"; + +/** + * Update org details + * Requires admin or owner role + */ +export const orgsUpdate = os.orgs.update + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, displayName, logoUrl } = input; + + // Lookup org and verify membership with admin+ role + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "admin"); + + // Build update object with only provided fields + const updates: Record = { updated_at: new Date() }; + if (displayName !== undefined) { + updates.display_name = displayName; + } + if (logoUrl !== undefined) { + updates.logo_url = logoUrl; + } + + await context.db + .updateTable("orgs") + .set(updates) + .where("id", "=", org.id) + .execute(); + }); + +/** + * Delete an org + * Requires owner role + * FK CASCADE handles deleting members, invites, and sites + */ +export const orgsDelete = os.orgs.delete + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and verify ownership + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "owner"); + + await context.db.deleteFrom("orgs").where("id", "=", org.id).execute(); + }); + +/** + * Leave an org + * Cannot leave if you're the only owner + * Uses transaction to prevent race condition where multiple owners leave simultaneously + */ +export const orgsLeave = os.orgs.leave + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and get membership + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + + await context.db.transaction().execute(async (trx) => { + // If user is an owner, check if they're the last one (with lock) + if (membership.role === "owner") { + const ownerCount = await countOwners(trx, org.id); + if (ownerCount === 1) { + throw new ORPCError("BAD_REQUEST", { + message: + "Cannot leave as the only owner. Transfer ownership or delete the organization.", + }); + } + } + + // Remove membership + await trx + .deleteFrom("org_members") + .where("org_id", "=", org.id) + .where("user_id", "=", context.user.id) + .execute(); + }); + }); diff --git a/apps/api-server/src/procedures/orgs/members.ts b/apps/api-server/src/procedures/orgs/members.ts new file mode 100644 index 0000000..1a39aba --- /dev/null +++ b/apps/api-server/src/procedures/orgs/members.ts @@ -0,0 +1,158 @@ +/** + * Org member management procedures - list, updateRole, remove + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; +import { + countOwners, + getMembership, + lookupOrgBySlug, + requireRole, +} from "./helpers.js"; + +/** + * List all members of an org + * Any member can view the member list + */ +export const membersList = os.orgs.members.list + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and verify membership + const org = await lookupOrgBySlug(context.db, slug); + await getMembership(context.db, org.id, context.user.id); + + // Get members with user details + const members = await context.db + .selectFrom("org_members") + .innerJoin("users", "users.id", "org_members.user_id") + .where("org_members.org_id", "=", org.id) + .select([ + "org_members.id", + "org_members.user_id", + "org_members.role", + "org_members.created_at", + "users.email", + "users.display_name", + ]) + .orderBy("org_members.created_at", "asc") + .execute(); + + return members.map((m) => ({ + id: m.id, + userId: m.user_id, + email: m.email, + displayName: m.display_name, + role: m.role, + createdAt: m.created_at, + })); + }); + +/** + * Update a member's role + * Only owners can change roles + * Uses transaction to prevent race condition when demoting owners + */ +export const membersUpdateRole = os.orgs.members.updateRole + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, userId, role: newRole } = input; + + // Lookup org and verify ownership + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + requireRole(membership, "owner"); + + await context.db.transaction().execute(async (trx) => { + // Get the target member's current membership + const targetMember = await trx + .selectFrom("org_members") + .select(["id", "role"]) + .where("org_id", "=", org.id) + .where("user_id", "=", userId) + .executeTakeFirst(); + + if (!targetMember) { + throw new ORPCError("NOT_FOUND", { message: "Member not found" }); + } + + // If demoting an owner, check if they're the last one + if (targetMember.role === "owner" && newRole !== "owner") { + const ownerCount = await countOwners(trx, org.id); + if (ownerCount === 1) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot demote the only owner", + }); + } + } + + // Update the role + await trx + .updateTable("org_members") + .set({ role: newRole }) + .where("id", "=", targetMember.id) + .execute(); + }); + }); + +/** + * Remove a member from an org + * Owners can remove anyone, admins can only remove members + * Uses transaction to prevent race condition when removing owners + */ +export const membersRemove = os.orgs.members.remove + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug, userId } = input; + + // Lookup org and verify membership + const org = await lookupOrgBySlug(context.db, slug); + const membership = await getMembership(context.db, org.id, context.user.id); + + await context.db.transaction().execute(async (trx) => { + // Get the target member + const targetMember = await trx + .selectFrom("org_members") + .select(["id", "role"]) + .where("org_id", "=", org.id) + .where("user_id", "=", userId) + .executeTakeFirst(); + + if (!targetMember) { + throw new ORPCError("NOT_FOUND", { message: "Member not found" }); + } + + // Permission checks + if (membership.role === "owner") { + // Owners can remove anyone except the last owner + if (targetMember.role === "owner") { + const ownerCount = await countOwners(trx, org.id); + if (ownerCount === 1) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot remove the only owner", + }); + } + } + } else if (membership.role === "admin") { + // Admins can only remove members (not owners or other admins) + if (targetMember.role !== "member") { + throw new ORPCError("FORBIDDEN", { + message: "Admins can only remove members", + }); + } + } else { + // Members cannot remove anyone + throw new ORPCError("FORBIDDEN", { + message: "Insufficient permissions", + }); + } + + // Remove the member + await trx + .deleteFrom("org_members") + .where("id", "=", targetMember.id) + .execute(); + }); + }); diff --git a/apps/api-server/src/procedures/orgs/sites.ts b/apps/api-server/src/procedures/orgs/sites.ts new file mode 100644 index 0000000..c2f3af4 --- /dev/null +++ b/apps/api-server/src/procedures/orgs/sites.ts @@ -0,0 +1,34 @@ +/** + * Org sites procedures - list + */ + +import { authMiddleware, os } from "../base.js"; +import { getMembership, lookupOrgBySlug } from "./helpers.js"; + +/** + * List all sites for an org + * Any member can view the site list + */ +export const sitesList = os.orgs.sites.list + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { slug } = input; + + // Lookup org and verify membership + const org = await lookupOrgBySlug(context.db, slug); + await getMembership(context.db, org.id, context.user.id); + + // Get sites + const sites = await context.db + .selectFrom("org_sites") + .where("org_id", "=", org.id) + .select(["id", "domain", "created_at"]) + .orderBy("created_at", "asc") + .execute(); + + return sites.map((s) => ({ + id: s.id, + domain: s.domain, + createdAt: s.created_at, + })); + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index cdf8caa..1190966 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -34,6 +34,22 @@ import { } from "./procedures/me/sessions.js"; import { setPassword } from "./procedures/me/set-password.js"; import { updateProfile } from "./procedures/me/update-profile.js"; +import { + invitesAccept, + invitesCancel, + invitesCreate, + invitesList, + membersList, + membersRemove, + membersUpdateRole, + orgsCreate, + orgsDelete, + orgsGet, + orgsLeave, + orgsList, + orgsUpdate, + sitesList, +} from "./procedures/orgs/index.js"; import { createAuthenticationOptions as createAuthOptions, createRegistrationOptions as createRegOptions, @@ -176,79 +192,11 @@ const setupProfile = os.me.setupProfile // - listSessions, revokeSession, revokeAllSessions // - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices -// Orgs procedures (all require auth) -const orgsList = os.orgs.list.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const orgsCreate = os.orgs.create.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const orgsGet = os.orgs.get.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const orgsUpdate = os.orgs.update.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const orgsDelete = os.orgs.delete.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const orgsLeave = os.orgs.leave.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -// Orgs members procedures -const membersList = os.orgs.members.list - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const membersUpdateRole = os.orgs.members.updateRole - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const membersRemove = os.orgs.members.remove - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -// Orgs invites procedures -const invitesList = os.orgs.invites.list - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const invitesCreate = os.orgs.invites.create - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const invitesCancel = os.orgs.invites.cancel - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const invitesAccept = os.orgs.invites.accept - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -// Orgs sites procedures -const sitesList = os.orgs.sites.list.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); +// Orgs procedures - imported from ./procedures/orgs/index.js +// - orgsList, orgsCreate, orgsGet, orgsUpdate, orgsDelete, orgsLeave +// - membersList, membersUpdateRole, membersRemove +// - invitesList, invitesCreate, invitesCancel, invitesAccept +// - sitesList // Admin orgs procedures (require superuser - for now just auth, will add superuser middleware later) const adminOrgsList = os.admin.orgs.list