diff --git a/apps/web/src/app/(dashboard)/team/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/team/_components/admin-only-notice.tsx new file mode 100644 index 00000000..51d24a16 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/admin-only-notice.tsx @@ -0,0 +1,19 @@ +import { Lock } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** + * Rendered when the members query 403s — the API is the authority on who is + * an admin (D-K). A plain card: no retry, no toast (the 403 is deterministic). + */ +export const AdminOnlyNotice = () => ( + +
+ +
+

Admins only

+

+ Managing members and invitations requires an organization admin. Ask an + admin if you need someone added to the team. +

+
+); diff --git a/apps/web/src/app/(dashboard)/team/_components/copy-link-button.tsx b/apps/web/src/app/(dashboard)/team/_components/copy-link-button.tsx new file mode 100644 index 00000000..0aa5c831 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/copy-link-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Copy, Check } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; + +export interface CopyLinkButtonProps { + token: string; +} + +/** + * Icon-only copy action for a pending invitation row — composes the join + * link from the browser's own origin (D-I). + */ +export const CopyLinkButton = ({ token }: CopyLinkButtonProps) => { + const { copied, copy } = useCopyToClipboard(); + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/invite-dialog.tsx b/apps/web/src/app/(dashboard)/team/_components/invite-dialog.tsx new file mode 100644 index 00000000..56dd9c48 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/invite-dialog.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { CircleCheck } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { useCreateInvitation } from "@/hooks/use-invitations"; +import type { InvitationRow } from "@/lib/api"; +import { InviteLinkField } from "./invite-link-field"; + +export interface InviteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const InviteDialog = ({ open, onOpenChange }: InviteDialogProps) => { + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"admin" | "member">("member"); + const [created, setCreated] = useState(null); + const createInvitation = useCreateInvitation(); + + const trimmedEmail = email.trim(); + const isEmailPlausible = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail); + + const handleCreate = () => { + if (!isEmailPlausible || createInvitation.isPending) return; + createInvitation.mutate( + { email: trimmedEmail, role }, + { onSuccess: (invitation) => setCreated(invitation) }, + ); + }; + + const handleClose = (value: boolean) => { + if (!value) { + setEmail(""); + setRole("member"); + setCreated(null); + } + onOpenChange(value); + }; + + // D-I: the link is composed from the browser's own origin — the server + // never guesses a public URL. + const joinLink = created + ? `${window.location.origin}/join/${created.token}` + : ""; + + return ( + + + {created ? ( + <> +
+
+ +
+ + Invitation created + + OneCLI open edition doesn't send email. Copy this link + and send it to {created.email} yourself. They + must sign in with that exact Google address, and the link + expires in 7 days. + + +
+
+ +
+ + + + + ) : ( + <> + + Invite a member + + Create an invitation link for a teammate. They must sign in with + the exact Google address you enter here. + + +
+
+ + setEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + autoFocus + /> +
+
+ + +
+
+ + + + + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/invite-link-field.tsx b/apps/web/src/app/(dashboard)/team/_components/invite-link-field.tsx new file mode 100644 index 00000000..3921c619 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/invite-link-field.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Copy, Check } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; + +export interface InviteLinkFieldProps { + link: string; +} + +/** Read-only join link + copy button (the api-key-card pattern). */ +export const InviteLinkField = ({ link }: InviteLinkFieldProps) => { + const { copied, copy } = useCopyToClipboard(); + + return ( +
+ e.currentTarget.select()} + /> + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/team/_components/local-mode-notice.tsx new file mode 100644 index 00000000..55f4ecbb --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/local-mode-notice.tsx @@ -0,0 +1,17 @@ +import { Users } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** Local auth mode has exactly one identity — the team surface is inert. */ +export const LocalModeNotice = () => ( + +
+ +
+

Team is unavailable in local mode

+

+ This instance runs in local auth mode, which has exactly one built-in + identity (admin@localhost). To invite teammates, configure Google OAuth + (NEXTAUTH_SECRET + GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) and restart. +

+
+); diff --git a/apps/web/src/app/(dashboard)/team/_components/member-row-actions.tsx b/apps/web/src/app/(dashboard)/team/_components/member-row-actions.tsx new file mode 100644 index 00000000..183c025c --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/member-row-actions.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal, Loader2 } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@onecli/ui/components/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@onecli/ui/components/tooltip"; +import { useUpdateOrgMember } from "@/hooks/use-org-members"; +import type { OrgMemberListRow } from "@/lib/api"; + +export interface MemberRowActionsProps { + member: OrgMemberListRow; + isYou: boolean; +} + +export const MemberRowActions = ({ member, isYou }: MemberRowActionsProps) => { + const [suspendOpen, setSuspendOpen] = useState(false); + const update = useUpdateOrgMember(); + + // Owners are untouchable through this surface, and the API rejects every + // self-change (self-suspend would lock the admin out of the undo surface). + const locked = isYou || member.role === "owner"; + const suspended = member.status === "suspended"; + + const setRole = (role: "admin" | "member") => + update.mutate({ userId: member.userId, input: { role } }); + + const handleSuspend = () => + update.mutate( + { userId: member.userId, input: { status: "suspended" } }, + { onSuccess: () => setSuspendOpen(false) }, + ); + + const handleReinstate = () => + update.mutate({ userId: member.userId, input: { status: "active" } }); + + if (locked) { + return ( + + + {/* span wrapper: disabled buttons swallow the hover events the + tooltip needs */} + + + + + + {isYou + ? "You cannot change your own membership." + : "The organization owner cannot be changed here."} + + + ); + } + + return ( + <> + + + + + + {member.role === "member" ? ( + setRole("admin")}> + Make admin + + ) : ( + setRole("member")}> + Make member + + )} + + {suspended ? ( + + Reinstate + + ) : ( + setSuspendOpen(true)} + > + Suspend + + )} + + + + + + + Suspend {member.email}? + + A suspended member loses all access immediately — every + authorization check treats them as a non-member until they are + reinstated. + + + + + Cancel + + { + e.preventDefault(); + handleSuspend(); + }} + disabled={update.isPending} + > + {update.isPending ? ( + <> + + Suspending... + + ) : ( + "Suspend" + )} + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/members-table.tsx b/apps/web/src/app/(dashboard)/team/_components/members-table.tsx new file mode 100644 index 00000000..fb34cc44 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/members-table.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { Badge } from "@onecli/ui/components/badge"; +import { Card } from "@onecli/ui/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { useAuth } from "@/providers/auth-provider"; +import type { OrgMemberListRow } from "@/lib/api"; +import { MemberRowActions } from "./member-row-actions"; +import { InviteDialog } from "./invite-dialog"; + +export interface MembersTableProps { + members: OrgMemberListRow[]; +} + +const roleBadgeVariant = (role: string) => + role === "owner" ? "default" : role === "admin" ? "secondary" : "outline"; + +export const MembersTable = ({ members }: MembersTableProps) => { + const { user } = useAuth(); + const [inviteOpen, setInviteOpen] = useState(false); + + // "You" is matched by EMAIL, case-insensitively — NOT by `user.id`: the + // auth context's id is the external auth id (providerAccountId), never the + // DB userId these rows carry. + const viewerEmail = user?.email?.toLowerCase(); + const isYou = (row: OrgMemberListRow) => + viewerEmail !== undefined && row.email.toLowerCase() === viewerEmail; + + return ( +
+
+

Members

+ +
+ + + + + Member + Role + Status + Joined + + + + + {members.map((row) => ( + + +
+ + {row.name ?? row.email} + {isYou(row) && ( + + (you) + + )} + + {row.name && ( + + {row.email} + + )} +
+
+ + {row.role} + + + + {row.status} + + + + {new Date(row.joinedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + +
+ ))} +
+
+
+ +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/pending-invitations.tsx b/apps/web/src/app/(dashboard)/team/_components/pending-invitations.tsx new file mode 100644 index 00000000..7b023661 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/pending-invitations.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import { Trash2, Loader2, Mail, TriangleAlert } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { Badge } from "@onecli/ui/components/badge"; +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { cn } from "@onecli/ui/lib/utils"; +import { useRevokeInvitation } from "@/hooks/use-invitations"; +import type { InvitationRow } from "@/lib/api"; +import { CopyLinkButton } from "./copy-link-button"; + +export interface PendingInvitationsProps { + invitations: InvitationRow[]; + loading: boolean; + /** Fetch failed — must not masquerade as the "no invitations" empty state. */ + error?: boolean; +} + +const statusBadgeVariant = (status: InvitationRow["status"]) => + status === "pending" + ? ("secondary" as const) + : status === "expired" || status === "cancelled" + ? ("outline" as const) + : ("default" as const); + +/** "in 6d" / "in 3h" / "expired" — expiry is in the future, unlike formatRelative. */ +const formatExpiry = (expiresAt: string) => { + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) return "expired"; + const hours = Math.floor(diff / (60 * 60 * 1000)); + if (hours < 1) return "in <1h"; + if (hours < 24) return `in ${hours}h`; + return `in ${Math.floor(hours / 24)}d`; +}; + +export const PendingInvitations = ({ + invitations, + loading, + error = false, +}: PendingInvitationsProps) => { + const [revokeTarget, setRevokeTarget] = useState(null); + const revoke = useRevokeInvitation(); + + const handleRevoke = () => { + if (!revokeTarget) return; + revoke.mutate(revokeTarget.id, { + onSuccess: () => setRevokeTarget(null), + }); + }; + + return ( +
+

Invitations

+ {loading ? ( + +
+ + +
+
+ ) : error ? ( + + +
+

+ Couldn't load invitations +

+

+ Something went wrong fetching the invitation list. Refresh the + page to try again. +

+
+
+ ) : invitations.length === 0 ? ( + +
+ +
+

No invitations yet

+

+ Invite a teammate to generate a join link you can send them. +

+
+ ) : ( + + + + + Email + Role + Invited by + Expires + Status + + + + + {invitations.map((row) => { + const inactive = row.status !== "pending"; + return ( + + {row.email} + {row.role} + {row.invitedByEmail} + + {row.status === "pending" + ? formatExpiry(row.expiresAt) + : "—"} + + + + {row.status === "cancelled" ? "revoked" : row.status} + + + +
+ {/* The link is only actionable while pending. */} + {row.status === "pending" && ( + <> + + + + )} +
+
+
+ ); + })} +
+
+
+ )} + + { + if (!open) setRevokeTarget(null); + }} + > + + + + Revoke the invitation for {revokeTarget?.email}? + + + The join link stops working immediately. You can invite this + address again later. + + + + + Cancel + + { + e.preventDefault(); + handleRevoke(); + }} + disabled={revoke.isPending} + > + {revoke.isPending ? ( + <> + + Revoking... + + ) : ( + "Revoke" + )} + + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/team/_components/team-content.tsx b/apps/web/src/app/(dashboard)/team/_components/team-content.tsx new file mode 100644 index 00000000..6622119f --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/_components/team-content.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { useOrgMembersList } from "@/hooks/use-org-members"; +import { useInvitations } from "@/hooks/use-invitations"; +import { LocalModeNotice } from "./local-mode-notice"; +import { AdminOnlyNotice } from "./admin-only-notice"; +import { MembersTable } from "./members-table"; +import { PendingInvitations } from "./pending-invitations"; + +export interface TeamContentProps { + /** Threaded from the RSC page (server-only auth mode); false = local mode. */ + teamEnabled: boolean; +} + +export const TeamContent = ({ teamEnabled }: TeamContentProps) => { + const members = useOrgMembersList(teamEnabled); + // The members query's 403 is the admin authority (D-K): a non-admin gets a + // deterministic error, so the invitations query never even starts for them. + const isAdmin = !members.isError; + const invitations = useInvitations(teamEnabled && isAdmin); + + if (!teamEnabled) return ; + + if (members.isPending) { + return ( +
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+ ); + } + + if (members.isError) return ; + + return ( +
+ + {/* A failed fetch must render as an error, never as "No invitations + yet" — with retry:false an isError query has data undefined, which + would otherwise fall into the empty state. */} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/team/loading.tsx b/apps/web/src/app/(dashboard)/team/loading.tsx new file mode 100644 index 00000000..bc889edc --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/loading.tsx @@ -0,0 +1,27 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; + +export default function TeamLoading() { + return ( +
+ +
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/team/page.tsx b/apps/web/src/app/(dashboard)/team/page.tsx new file mode 100644 index 00000000..e300ebf7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/team/page.tsx @@ -0,0 +1,29 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { TeamContent } from "./_components/team-content"; + +export const metadata: Metadata = { + title: "Team", +}; + +export default function TeamPage() { + // Auth mode is server-only (fs-backed runtime config), so it is resolved + // here and threaded down as a prop (the AgentsContent precedent). No + // server-side auth/role resolution at page level — no dashboard page does + // it, and the API's 403 is the authority on who is an admin (D-K). + const teamEnabled = getAuthMode() !== "local"; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/join/[token]/_components/accept-invitation-button.tsx b/apps/web/src/app/join/[token]/_components/accept-invitation-button.tsx new file mode 100644 index 00000000..422579d1 --- /dev/null +++ b/apps/web/src/app/join/[token]/_components/accept-invitation-button.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@onecli/ui/components/button"; +import { acceptInvitationAction } from "@/lib/actions/org-invitations"; + +export interface AcceptInvitationButtonProps { + token: string; + organizationName: string; +} + +/** + * EXPLICIT click, never auto-accept on mount: a visitor lured onto an invite + * link must not be silently joined to someone's organization. + */ +export const AcceptInvitationButton = ({ + token, + organizationName, +}: AcceptInvitationButtonProps) => { + const router = useRouter(); + const [joining, setJoining] = useState(false); + + const handleJoin = async () => { + setJoining(true); + const result = await acceptInvitationAction(token); + if (result.ok) { + // replace, not push: the tokened URL should not stay in history. + router.replace("/overview"); + return; + } + toast.error(result.error); + setJoining(false); + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/join/[token]/_components/invite-summary.tsx b/apps/web/src/app/join/[token]/_components/invite-summary.tsx new file mode 100644 index 00000000..abc7b8ae --- /dev/null +++ b/apps/web/src/app/join/[token]/_components/invite-summary.tsx @@ -0,0 +1,27 @@ +import type { InvitationView } from "@onecli/api/services/org-invitation-service"; + +export interface InviteSummaryProps { + /** Only the states that carry the full summary fields render this block. */ + view: Extract< + InvitationView, + { state: "signin-required" | "ready" | "other-org" } + >; +} + +/** Inviter / invited address / role summary shown on the join card. */ +export const InviteSummary = ({ view }: InviteSummaryProps) => ( +
+
+
Invited by
+
{view.invitedByEmail}
+
+
+
Invited address
+
{view.invitedEmail}
+
+
+
Role
+
{view.role}
+
+
+); diff --git a/apps/web/src/app/join/[token]/_components/join-card.tsx b/apps/web/src/app/join/[token]/_components/join-card.tsx new file mode 100644 index 00000000..eff7f793 --- /dev/null +++ b/apps/web/src/app/join/[token]/_components/join-card.tsx @@ -0,0 +1,187 @@ +import Link from "next/link"; +import { TriangleAlert } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@onecli/ui/components/card"; +import { Button } from "@onecli/ui/components/button"; +import type { InvitationView } from "@onecli/api/services/org-invitation-service"; +import { SignInToJoinButton } from "./sign-in-to-join-button"; +import { AcceptInvitationButton } from "./accept-invitation-button"; +import { InviteSummary } from "./invite-summary"; + +export interface JoinCardProps { + view: InvitationView; + token: string; +} + +/** + * Pure switch over `view.state` — every policy decision was made server-side + * by `describeInvitation` (D-L). No external links anywhere on this page: the + * URL path carries the invite token (D-E). + */ +export const JoinCard = ({ view, token }: JoinCardProps) => { + switch (view.state) { + case "not-found": + // Generic on purpose: an unknown token must not be an oracle. + return ( + + + Invitation not found + + This invitation link is not valid. Check that you copied the full + link, or ask the person who invited you for a new one. + + + + ); + + case "revoked": + return ( + + + Invitation revoked + + Your invitation to {view.organizationName} was revoked. Ask an + admin to invite you again. + + + + ); + + case "expired": + return ( + + + Invitation expired + + Your invitation to {view.organizationName} has expired (invitation + links last 7 days). Ask an admin to invite you again. + + + + ); + + case "accepted": + return ( + + + Invitation already used + + This invitation to {view.organizationName} has already been + accepted. If that wasn't you, ask an admin to invite you + again. + + + + ); + + case "already-member": + return ( + + + + You're already in {view.organizationName} + + + Nothing to do here — your membership is active. + + + + + + + ); + + case "suspended": + return ( + + + Membership suspended + + Your membership in {view.organizationName} is suspended, and an + invitation link cannot bypass a suspension. Ask an admin to + reinstate you. + + + + ); + + case "wrong-email": + return ( + + + This invitation is for someone else + + The invitation to {view.organizationName} was issued to{" "} + {view.invitedEmail}, but you are signed in as{" "} + {view.viewerEmail}. Sign in with the invited + address to join. + + + + ); + + case "signin-required": + return ( + + + Join {view.organizationName} + + You've been invited to {view.organizationName} on OneCLI. + Sign in with {view.invitedEmail} to accept — the + invitation only works for that exact address. + + + + + + + + + + ); + + case "other-org": + case "ready": + return ( + + + Join {view.organizationName} + + You've been invited to {view.organizationName} on OneCLI. + + + + + {view.state === "other-org" && ( + // D-A: joining works, but the dashboard keeps opening this + // user's own workspace until org switching ships. Tell the + // truth up front rather than surprise them after the click. +
+ +

+ You already have your own workspace. You can join{" "} + {view.organizationName}, but the dashboard will keep opening + your own workspace for now — organization switching is coming + later. +

+
+ )} +
+ + + +
+ ); + } +}; diff --git a/apps/web/src/app/join/[token]/_components/sign-in-to-join-button.tsx b/apps/web/src/app/join/[token]/_components/sign-in-to-join-button.tsx new file mode 100644 index 00000000..9d21c3f0 --- /dev/null +++ b/apps/web/src/app/join/[token]/_components/sign-in-to-join-button.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@onecli/ui/components/button"; +import { useAuth } from "@/providers/auth-provider"; + +/** + * next-auth's `signIn()` defaults redirectTo to the current URL, so the + * OAuth round-trip lands back on this /join/ page — no state to carry. + */ +export const SignInToJoinButton = () => { + const { signIn } = useAuth(); + const [loading, setLoading] = useState(false); + + const handleSignIn = async () => { + setLoading(true); + try { + await signIn(); + } catch { + toast.error("Sign-in failed. Please try again."); + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/apps/web/src/app/join/[token]/page.tsx b/apps/web/src/app/join/[token]/page.tsx new file mode 100644 index 00000000..f44cce77 --- /dev/null +++ b/apps/web/src/app/join/[token]/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { getServerSession } from "@/lib/auth/server"; +import { describeInvitation } from "@onecli/api/services/org-invitation-service"; +import { JoinCard } from "./_components/join-card"; + +// D-E: the token lives in the URL path, so this page must never leak it — +// no-referrer, and the page renders no external links. +export const metadata: Metadata = { + title: "Join", + referrer: "no-referrer", +}; + +// Next 16 async params. +interface Props { + params: Promise<{ token: string }>; +} + +/** + * The invitation landing page. Deliberately OUTSIDE `(dashboard)`: that + * layout's `/v1/auth/session` call is the only org-bootstrap trigger, and + * placing /join inside it would guarantee the bootstrap-vs-accept race. A + * server component with an explicit Join button — never a side-effecting GET + * (prefetchers and link unfurlers hit invite links). + */ +export default async function JoinPage({ params }: Props) { + // Local mode has exactly one built-in identity — invitations don't exist. + if (getAuthMode() === "local") notFound(); + + const { token } = await params; + const session = await getServerSession(); + const view = await describeInvitation( + token, + // `session.id` is the EXTERNAL auth id — describeInvitation resolves the + // DB user itself (and never creates one during a GET render). + session ? { externalAuthId: session.id, email: session.email } : null, + ); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/hooks/use-invitations.ts b/apps/web/src/hooks/use-invitations.ts new file mode 100644 index 00000000..0e155b9c --- /dev/null +++ b/apps/web/src/hooks/use-invitations.ts @@ -0,0 +1,54 @@ +"use client"; + +// No client-side gateway flush here: invitation mutations run through audited +// API routes that flush the gateway server-side (withAudit's org invalidation). + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { invitations } from "@/lib/api"; +import type { CreateInvitationInput } from "@/lib/api"; +import { fetchAllPages } from "@/lib/api/pagination"; +import { queryKeys } from "@/lib/api/keys"; + +// The API pages with cursors (§3.5); the UI drains all pages and filters +// client-side (the connection-agents dialog pattern). +const PAGE_LIMIT = 200; + +export const useInvitations = (enabled: boolean) => + useQuery({ + queryKey: queryKeys.invitations.list(), + queryFn: () => + fetchAllPages((cursor) => + invitations.list({ limit: PAGE_LIMIT, cursor }), + ), + enabled, + // Directory routes are admin-only; a non-admin gets a deterministic 403, + // which is expected, not retryable (mirrors useOrgMembersList). + retry: false, + }); + +export const useCreateInvitation = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateInvitationInput) => invitations.create(input), + // No success toast: the invite dialog switches to its copy-link state, + // which IS the success feedback. + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.invitations.all() }); + }, + // Surface the server reason (already a member, pending invite, cap). + onError: (err) => toast.error(err.message), + }); +}; + +export const useRevokeInvitation = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (invitationId: string) => invitations.revoke(invitationId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.invitations.all() }); + toast.success("Invitation revoked"); + }, + onError: (err) => toast.error(err.message), + }); +}; diff --git a/apps/web/src/hooks/use-org-members.ts b/apps/web/src/hooks/use-org-members.ts index 17afaf21..c7c0bf49 100644 --- a/apps/web/src/hooks/use-org-members.ts +++ b/apps/web/src/hooks/use-org-members.ts @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { orgMembers } from "@/lib/api"; import type { UpdateOrgMemberInput } from "@/lib/api"; @@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/api/keys"; const PAGE_LIMIT = 200; -/** Org members via the directory API (all pages) — the group member picker's candidates. */ +/** Org members via the directory API (all pages) — the /team members table. */ export const useOrgMembersList = (enabled: boolean) => useQuery({ queryKey: queryKeys.orgMembers.list(), @@ -17,18 +17,20 @@ export const useOrgMembersList = (enabled: boolean) => fetchAllPages((cursor) => orgMembers.list({ limit: PAGE_LIMIT, cursor })), enabled, // Directory routes are admin-only; a non-admin gets a deterministic 403, - // which is expected, not retryable. + // which the team page RENDERS (its admin-only notice) rather than + // retries — the API is the authority on who is an admin. retry: false, }); /** - * Member lifecycle + policy-flag mutations (suspend / reinstate / SSO - * exemption). The members list is server-rendered on the team page, so - * there is no query cache to invalidate — callers `router.refresh()` in - * their own onSuccess to re-render the list. + * Member mutations: lifecycle (suspend / reinstate) and org role + * (admin / member) — one change per call. The members list is a client query + * (`useOrgMembersList` feeds the /team table), so a successful change + * invalidates it here. */ -export const useUpdateOrgMember = () => - useMutation({ +export const useUpdateOrgMember = () => { + const qc = useQueryClient(); + return useMutation({ mutationFn: ({ userId, input, @@ -36,5 +38,9 @@ export const useUpdateOrgMember = () => userId: string; input: UpdateOrgMemberInput; }) => orgMembers.update(userId, input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: queryKeys.orgMembers.all() }); + }, onError: (err) => toast.error(err.message), }); +}; diff --git a/apps/web/src/lib/actions/org-invitations.ts b/apps/web/src/lib/actions/org-invitations.ts new file mode 100644 index 00000000..ac896f86 --- /dev/null +++ b/apps/web/src/lib/actions/org-invitations.ts @@ -0,0 +1,112 @@ +"use server"; + +// Registers the OSS role resolver for the server-action module graph +// (mirrors resolve-user.ts) — `ensureMemberDefaultProject` runs rbac-gated +// project provisioning downstream of the accept. +import "@/lib/init/server"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { getServerSession } from "@/lib/auth/server"; +import { safeAction, type ActionResult } from "@/lib/safe-action"; +import { + acceptInvitation, + describeInvitation, + resolveInviteeUser, +} from "@onecli/api/services/org-invitation-service"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, +} from "@onecli/api/services/audit-service"; + +/** + * Accept an invitation link — the ONLY accept surface (D-G: no HTTP route). + * + * Deliberately does NOT use `resolveProjectContext`: the invitee has no + * project yet, so it would throw. `resolveInviteeUser` creates the DB user + * row when the visitor is brand-new — this being the invitee's FIRST + * authenticated write (before any `/v1/auth/session` sync) is what keeps the + * org bootstrap from racing the accept. `withAudit` (not `recordAuditEvent`) + * is load-bearing: it also flushes the gateway's org cache. Source stays the + * default APP — this really is an interactive dashboard action. + */ +export const acceptInvitationAction = async ( + token: string, +): Promise> => + safeAction(async () => { + // Mirror the /join page's guard (it notFound()s in local mode): the + // action must not be invocable with the ambient local identity either. + if (getAuthMode() === "local") { + throw new Error("Invitations are not available in local auth mode."); + } + + const session = await getServerSession(); + if (!session) throw new Error("Not authenticated"); + + // Validate BEFORE mutating anything: a failed accept must not mint a DB + // user row for a brand-new visitor. That row would permanently disqualify + // the auth-session bootstrap gate (`!existingUser` is read before the + // upsert) and strand the account on /create-org, which does not exist in + // OSS. `describeInvitation` never writes — a revoked/expired/bogus token + // bails out here with the invitee's slate still clean. `acceptInvitation` + // re-checks every guard authoritatively afterwards; the describe→accept + // window is milliseconds wide and its failure mode is a re-invite, not an + // orphaned account. + const view = await describeInvitation(token, { + externalAuthId: session.id, + email: session.email, + }); + switch (view.state) { + case "ready": + case "other-org": + // already-member implies the DB user row exists (describeInvitation + // found it), so resolving below creates nothing; accept is idempotent. + case "already-member": + break; + case "not-found": + throw new Error("Invitation not found."); + case "wrong-email": + throw new Error( + `This invitation was issued to ${view.invitedEmail}, but you are signed in as ${view.viewerEmail}. Sign in with the invited address to join.`, + ); + case "suspended": + throw new Error( + "Your membership in this organization is suspended. Ask an admin to reinstate you — an invitation cannot bypass a suspension.", + ); + case "expired": + throw new Error("This invitation has expired."); + case "revoked": + case "accepted": + // signin-required is unreachable with a session; treat it as inactive. + case "signin-required": + throw new Error("This invitation is no longer active."); + } + + const user = await resolveInviteeUser( + session.id, + session.email, + session.name, + ); + + const result = await withAudit( + // The D-C match compares the SESSION email — the same value + // `describeInvitation` compared above — so a stale stored email can + // never make the page say "ready" while the accept 403s naming an + // address the user isn't signed in with. `user.id` still keys the + // membership write. + () => acceptInvitation(token, user.id, session.email), + (r) => ({ + organizationId: r.organizationId, + userId: user.id, + userEmail: session.email, + action: AUDIT_ACTIONS.CREATE, + service: AUDIT_SERVICES.MEMBER, + metadata: { + invitationId: r.invitationId, + role: r.role, + via: "invitation", + }, + }), + ); + + return { projectId: result.projectId }; + }); diff --git a/apps/web/src/lib/actions/resolve-user.ts b/apps/web/src/lib/actions/resolve-user.ts index e02158ee..7cc21845 100644 --- a/apps/web/src/lib/actions/resolve-user.ts +++ b/apps/web/src/lib/actions/resolve-user.ts @@ -4,7 +4,11 @@ import "@/lib/init/server"; import { headers } from "next/headers"; import { db } from "@onecli/db"; import { getServerSession } from "@/lib/auth/server"; -import { findUserDefaultProject } from "@onecli/api/services/organization-service"; +import { + activeMembershipWhere, + findUserDefaultProject, +} from "@onecli/api/services/organization-service"; +import { canAccessProjectAsUser } from "@onecli/api/middleware/auth/resolve"; export interface UserContext { userId: string; @@ -22,6 +26,13 @@ export interface ResolveOptions { * active project. Tries the x-project-id header first (set by proxy.ts from * the URL path), then falls back to the user's default project (appropriate * for OSS where there is a single org/project). + * + * The header arm gates exactly like the API's `resolveProjectId`: SUSPENDED + * memberships are filtered out (they are non-members to every authorization + * check) and the project itself must pass `canAccessProjectAsUser`, so an + * org-membership alone never grants a plain member access to a project they + * hold no ProjectAccess binding on. Keeping the two surfaces identical is the + * point — a header the API rejects must not open a dashboard context. */ export const resolveProjectContext = async ( options?: ResolveOptions, @@ -36,6 +47,7 @@ export const resolveProjectContext = async ( id: true, email: true, organizationMemberships: { + where: activeMembershipWhere, select: { organizationId: true }, }, }, @@ -57,7 +69,7 @@ export const resolveProjectContext = async ( }, select: { id: true, organizationId: true }, }); - if (project) { + if (project && (await canAccessProjectAsUser(user.id, project))) { return { userId: user.id, userEmail: user.email, diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index f665ce11..9b662576 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -7,6 +7,7 @@ import * as projects from "./projects"; import * as projectAccess from "./project-access"; import * as domains from "./domains"; import * as orgMembers from "./org-members"; +import * as invitations from "./invitations"; import * as groups from "./groups"; import * as roleMappings from "./role-mappings"; import * as ssoConnections from "./sso-connections"; @@ -30,6 +31,7 @@ export { projectAccess, domains, orgMembers, + invitations, groups, roleMappings, ssoConnections, @@ -57,7 +59,10 @@ export type { OrgDomain, OrgSsoEnforcement, OrgMemberRow, + UpdatedOrgMember, UpdateOrgMemberInput, + InvitationRow, + CreateInvitationInput, DirectoryPage, DirectoryListParams, GroupRow, diff --git a/apps/web/src/lib/api/invitations.ts b/apps/web/src/lib/api/invitations.ts new file mode 100644 index 00000000..98dd7ef8 --- /dev/null +++ b/apps/web/src/lib/api/invitations.ts @@ -0,0 +1,31 @@ +import { apiDelete, apiGet, apiPost } from "./client"; +import type { + CreateInvitationInput, + DirectoryPage, + DirectoryListParams, + InvitationRow, +} from "./types"; + +// Link-based org invitations: the admin surface (list / create / revoke). +// There is no accept call here — accepting runs through a Server Action +// (`@/lib/actions/org-invitations`), never an HTTP route. The join link is +// composed client-side from `window.location.origin` + the row's token. +const base = "/v1/org/invitations"; + +export const list = ( + params: DirectoryListParams & { status?: InvitationRow["status"] } = {}, +) => { + const search = new URLSearchParams(); + if (params.limit) search.set("limit", String(params.limit)); + if (params.cursor) search.set("cursor", params.cursor); + if (params.q) search.set("q", params.q); + if (params.status) search.set("status", params.status); + const qs = search.toString(); + return apiGet>(`${base}${qs ? `?${qs}` : ""}`); +}; + +export const create = (input: CreateInvitationInput) => + apiPost(base, input); + +export const revoke = (invitationId: string) => + apiDelete(`${base}/${invitationId}`); diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index 6d5d2b02..4138a1c3 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -40,6 +40,10 @@ export const queryKeys = { all: () => ["org-members", ...scope()] as const, list: () => [...queryKeys.orgMembers.all(), "list"] as const, }, + invitations: { + all: () => ["invitations", ...scope()] as const, + list: () => [...queryKeys.invitations.all(), "list"] as const, + }, ssoConnections: { all: () => ["sso-connections", ...scope()] as const, list: () => [...queryKeys.ssoConnections.all(), "list"] as const, diff --git a/apps/web/src/lib/api/org-members.ts b/apps/web/src/lib/api/org-members.ts index 86b8175a..c27ea1ff 100644 --- a/apps/web/src/lib/api/org-members.ts +++ b/apps/web/src/lib/api/org-members.ts @@ -1,6 +1,6 @@ import { apiGet, apiPatch } from "./client"; import type { - OrgMemberRow, + UpdatedOrgMember, UpdateOrgMemberInput, DirectoryPage, DirectoryListParams, @@ -8,13 +8,13 @@ import type { GroupRow, } from "./types"; -// Member lifecycle (suspend/reinstate) + break-glass SSO exemption, plus the -// §3.5 directory reads (the members list feeds the group member picker; the -// team page's own list stays server-rendered). +// Member lifecycle (suspend/reinstate) and org-role changes, plus the §3.5 +// directory reads (the members list feeds both the group member picker and +// the /team members table — client-side queries on both surfaces). const base = "/v1/org/members"; export const update = (userId: string, input: UpdateOrgMemberInput) => - apiPatch(`${base}/${userId}`, input); + apiPatch(`${base}/${userId}`, input); export const list = ( params: DirectoryListParams & { status?: "active" | "suspended" } = {}, diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index dfa40ef7..704d24b3 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -187,10 +187,12 @@ export interface OrgSsoEnforcement { exemptMemberCount: number; } -// PATCH /v1/org/members/:userId — exactly one change per request. +// PATCH /v1/org/members/:userId — exactly one change per request. `owner` is +// not assignable here (owner transfer is a separate operation); the `ssoExempt` +// arm is gone with the SSO feature it belonged to. export type UpdateOrgMemberInput = | { status: "active" | "suspended" } - | { ssoExempt: boolean }; + | { role: "admin" | "member" }; export interface OrgMemberRow { userId: string; @@ -200,6 +202,14 @@ export interface OrgMemberRow { revocation?: string; } +/** + * PATCH /v1/org/members/:userId response. The server echoes back only the + * facet it changed, so the response mirrors the request's single-change shape. + */ +export type UpdatedOrgMember = + | Pick + | { userId: string; role: string }; + export interface ResourceCounts { agents: number; apps: number; @@ -293,6 +303,25 @@ export interface OrgMemberListRow { joinedAt: string; } +// Link-based org invitations (`/v1/org/invitations`). +export interface InvitationRow { + id: string; + email: string; + role: string; + /** Projected: a stored "pending" past its expiresAt reads "expired". */ + status: "pending" | "accepted" | "cancelled" | "expired"; + invitedByEmail: string; + expiresAt: string; + createdAt: string; + /** Raw link token — admin-only surface; the UI composes /join/. */ + token: string; +} + +export interface CreateInvitationInput { + email: string; + role: "admin" | "member"; +} + // ── Shared policy identity/condition shapes ────────────────────────────────── // Used by the editor's PolicyRuleV2. Project rules target a specific agent or // "any" (empty); org rules target directory identities (user / user-group). diff --git a/apps/web/src/lib/init/api.ts b/apps/web/src/lib/init/api.ts index 0c9b01e8..a4a3faa9 100644 --- a/apps/web/src/lib/init/api.ts +++ b/apps/web/src/lib/init/api.ts @@ -1,5 +1,7 @@ import type { CreateApiAppOptions } from "@onecli/api"; import { ossNewProjectPolicySeeder } from "@onecli/api/services/policy-oss-cutover"; +import { ossRoleResolver } from "@onecli/api/services/org-role-resolver"; +import { registerOssOrgRoutes } from "@onecli/api/routes/org"; /** * The OSS edition's API wiring. Every EE edition ALIASES THIS FILE AWAY @@ -7,12 +9,25 @@ import { ossNewProjectPolicySeeder } from "@onecli/api/services/policy-oss-cutov * here is OSS-only by construction: * * - the new-project seeder gives fresh projects their published Default Rule — - * the per-project enforce signal — pinned to ALLOW since step 6. + * the per-project enforce signal — pinned to ALLOW since step 6; + * - the role resolver backs `CAPS.rbac` (now true for OSS): it reads the + * org-membership row and is a hard prerequisite for the flag — with rbac on + * and no resolver, every access check reads "no role" and denies; + * - the org routes register the OSS `/v1/org/*` surface. * * No `policyValidator` is wired: the provider-hook default is permissive, so * granular resource scoping and cloud-only app targets are accepted at the API - * layer. The gateway does not yet ENFORCE resource scoping — see Tier 3. + * layer (the gateway enforces resource scoping — see the Tier 3 work). + * + * `eeRoutes` reads oddly for an OSS registration, but it IS the intended seam: + * it is the one hook `createApiApp` exposes for edition-owned routes, and every + * EE edition aliases this whole file away, so nothing here can collide with + * theirs. Registering these routes in the shared `app.ts` instead would put + * edition-specific paths in an upstream-merged file and let Hono's + * first-registration-wins silently shadow an EE route. */ export const eeOverrides: CreateApiAppOptions | undefined = { newOrgPolicySeeder: ossNewProjectPolicySeeder, + roleResolver: ossRoleResolver, + eeRoutes: registerOssOrgRoutes, }; diff --git a/apps/web/src/lib/init/server.ts b/apps/web/src/lib/init/server.ts index 70d17960..d7c5b3ea 100644 --- a/apps/web/src/lib/init/server.ts +++ b/apps/web/src/lib/init/server.ts @@ -1 +1,17 @@ -// OSS: no server-side EE initialization needed. +import { initRoleResolver } from "@onecli/api"; +import { ossRoleResolver } from "@onecli/api/services/org-role-resolver"; + +/** + * OSS server-side initialization. Every EE edition ALIASES THIS FILE AWAY + * (`next.config.js` → `@/ee/init/server` / `@/ee/onprem/init/server`), so + * anything here is OSS-only by construction. + * + * The role resolver is registered here as well as through `createApiApp` + * (`@/lib/init/api`): with `CAPS.rbac` on, the SERVER-ACTION surface also runs + * access checks (`resolveProjectContext` → `canAccessProjectAsUser`), and that + * module graph never imports the Hono app. Without this the resolver would be + * null there and every header-scoped project check would fail closed. + * `initRoleResolver` just assigns the singleton, so installing the same + * resolver from both seams is safe. + */ +initRoleResolver(ossRoleResolver); diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index 511fd525..a34a1a38 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -5,6 +5,7 @@ import { Plug, Activity, User, + Users, KeyRound, ShieldCheck, Globe, @@ -27,6 +28,9 @@ export const navItems: NavItem[] = [ { title: "Agents", url: "/agents", icon: Bot }, { title: "Connections", url: "/connections", icon: Plug }, { title: "Activity", url: "/activity", icon: Activity }, + // Always visible (D-J): the page itself degrades for non-admins and in + // local auth mode — hiding the item would require a session role field. + { title: "Team", url: "/team", icon: Users }, { title: "Settings", url: "/settings", icon: Settings }, ]; diff --git a/packages/api/package.json b/packages/api/package.json index 7018b1cd..61d96e95 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,7 +9,9 @@ "./services/*": "./src/services/*.ts", "./services/policy-legacy-migration": "./src/services/policy-legacy-migration/index.ts", "./services/policy-grant-conversion": "./src/services/policy-grant-conversion/index.ts", + "./routes/org": "./src/routes/org/index.ts", "./routes/*": "./src/routes/*.ts", + "./middleware/*": "./src/middleware/*.ts", "./lib/*": "./src/lib/*.ts", "./validations/*": "./src/validations/*.ts", "./apps/*": "./src/apps/*.ts", diff --git a/packages/api/src/lib/cursor.ts b/packages/api/src/lib/cursor.ts new file mode 100644 index 00000000..34525575 --- /dev/null +++ b/packages/api/src/lib/cursor.ts @@ -0,0 +1,92 @@ +/** + * Opaque keyset-cursor helpers for the directory-scale list envelope. + * + * Every `/v1/org/*` directory list answers with `{ data, nextCursor }`; the + * cursor is an opaque base64url blob encoding the ordering key of the last row + * on the page. Callers must never parse it — the client only ever echoes it + * back (`fetchAllPages` drains pages until `nextCursor` is null). + * + * A malformed cursor is treated as ABSENT (first page) rather than an error: a + * stale bookmark or a truncated query string must never 500, and a cursor + * carries no authority — the scope always comes from the caller's auth context. + * + * Pure and dependency-free; shared by every directory list. + */ + +/** The cursor envelope shared by every directory-scale list. */ +export interface DirectoryPage { + data: T[]; + nextCursor: string | null; +} + +/** + * Part separator. NUL can't occur in the values we encode (ISO timestamps and + * ids), so a split is unambiguous. + */ +const SEPARATOR = "\u0000"; + +export const DIRECTORY_LIMIT_MIN = 1; +/** The web client asks for 200 at a time (`use-groups.ts`); that is the ceiling. */ +export const DIRECTORY_LIMIT_MAX = 200; +export const DIRECTORY_LIMIT_DEFAULT = 50; + +/** Encode an ordering key (e.g. `[createdAtIso, userId]`) into an opaque cursor. */ +export const encodeCursor = (parts: string[]): string => + Buffer.from(parts.join(SEPARATOR), "utf8").toString("base64url"); + +/** + * Decode a cursor back into its `expectedParts` ordering-key components, or + * `null` when it is absent or malformed (wrong arity, empty component, + * undecodable) — callers then serve the first page. + */ +export const decodeCursor = ( + raw: string | undefined | null, + expectedParts: number, +): string[] | null => { + if (!raw) return null; + let decoded: string; + try { + decoded = Buffer.from(raw, "base64url").toString("utf8"); + } catch { + return null; + } + if (!decoded) return null; + const parts = decoded.split(SEPARATOR); + if (parts.length !== expectedParts) return null; + if (parts.some((part) => part.length === 0)) return null; + return parts; +}; + +/** + * Clamp a requested page size into `[1, 200]`. The route schemas reject + * out-of-range values outright; this is the service-level backstop so a direct + * caller can never ask for an unbounded page. + */ +export const clampDirectoryLimit = (limit?: number): number => { + if (limit === undefined || !Number.isFinite(limit)) + return DIRECTORY_LIMIT_DEFAULT; + return Math.min( + DIRECTORY_LIMIT_MAX, + Math.max(DIRECTORY_LIMIT_MIN, Math.trunc(limit)), + ); +}; + +/** + * Build the page envelope from an over-fetched row set: query `limit + 1` rows, + * hand them here, and the extra row (if any) becomes the `nextCursor` signal + * rather than a count query. `keyOf` must return the SAME ordering key the + * query sorts by, or pagination will skip/repeat rows. + */ +export const toDirectoryPage = ( + rows: T[], + limit: number, + keyOf: (row: T) => string[], +): DirectoryPage => { + if (rows.length <= limit) return { data: rows, nextCursor: null }; + const data = rows.slice(0, limit); + const last = data[data.length - 1]; + return { + data, + nextCursor: last === undefined ? null : encodeCursor(keyOf(last)), + }; +}; diff --git a/packages/api/src/lib/edition.test.ts b/packages/api/src/lib/edition.test.ts index bb4abbad..c678f07f 100644 --- a/packages/api/src/lib/edition.test.ts +++ b/packages/api/src/lib/edition.test.ts @@ -48,7 +48,7 @@ describe("capabilitiesFor", () => { billing: false, orgScopedUI: false, webSurface: "full", - rbac: false, + rbac: true, }); }); diff --git a/packages/api/src/lib/edition.ts b/packages/api/src/lib/edition.ts index e533e913..2178c830 100644 --- a/packages/api/src/lib/edition.ts +++ b/packages/api/src/lib/edition.ts @@ -72,7 +72,9 @@ export interface Capabilities { /** * Role-based access control is active — role enforcement in the access checks * (project access, org-admin guard, api-key) AND the member/role management UI - * (the Team screen). Cloud only for now; onprem flips it true when it gains RBAC. + * (the Team screen). Cloud and oss; onprem flips it true when it gains RBAC. + * Requires the edition to register a `RoleResolver` (OSS does so from its init + * seam): with rbac on and no resolver every check reads "no role" and denies. * Distinct from `multi-org` (how many orgs) and the `tenancy` model. */ rbac: boolean; @@ -85,7 +87,7 @@ const CAPABILITIES: Record = { billing: false, orgScopedUI: false, webSurface: "full", - rbac: false, + rbac: true, }, cloud: { auth: "cognito", diff --git a/packages/api/src/middleware/auth/api-key.test.ts b/packages/api/src/middleware/auth/api-key.test.ts new file mode 100644 index 00000000..29528cc0 --- /dev/null +++ b/packages/api/src/middleware/auth/api-key.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// API-key authentication under OSS + `CAPS.rbac`, through the REAL app: the +// real `auth()` middleware, the real `canAccessProjectAsUser` gate, and the +// real `ossRoleResolver` registered the way production registers it. +// +// This is the lockout regression suite for the rbac flip. The single most +// important case is the FIRST one: an ordinary single-user instance whose +// project predates ProjectAccess bindings must keep authenticating, because its +// user is the org owner. Everything else here is the other side of that coin — +// the gate must still say no to suspended, unknown-role and non-member users. +// +// The suite is written so it MUST FAIL if `ossRoleResolver` is swapped for a +// permissive stub like `{ getUserRole: async () => "owner" }`: the suspended-*, +// no-membership, unknown-role and org-key-member cases all depend on the real +// resolver returning null. + +const USER = "user-1"; +const ORG = "org-1"; +const PROJECT = "proj-1"; +const OTHER_PROJECT = "proj-2"; +const PROJECT_KEY = "oc_project-key"; +const ORG_KEY = "oc_org_key"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +const state = vi.hoisted(() => ({ + /** The membership row the resolver reads; null = no row at all. */ + member: { role: "owner", status: "active" } as { + role: string; + status: string; + } | null, + /** Whether a ProjectAccess binding exists for the user on the project. */ + hasBinding: false, +})); + +vi.mock("@onecli/db", () => ({ + Prisma: {}, + db: { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_key") + return { + userId: "user-1", + organizationId: "org-1", + scope: "organization", + }; + if (where.key === "oc_project-key") + return { userId: "user-1", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async () => ({ id: "user-1", email: "user@example.com" }), + }, + organizationMember: { + findUnique: async () => state.member, + findFirst: async () => + state.member ? { organizationId: "org-1" } : null, + }, + project: { + findUnique: async ({ where }: { where: { id?: string } }) => + where.id === "proj-1" + ? { id: "proj-1", organizationId: "org-1" } + : null, + findFirst: async ({ where }: { where?: { id?: string } }) => + where?.id === undefined || where.id === "proj-1" + ? { id: "proj-1", organizationId: "org-1" } + : null, + }, + projectAccess: { + findFirst: async () => (state.hasBinding ? { id: "binding-1" } : null), + }, + agent: { findMany: async () => [] }, + }, +})); + +import { createApiApp } from "../../app"; +import { ossRoleResolver } from "../../services/org-role-resolver"; +import { initStrictApiKeyAuth } from "../../providers"; +import { auth } from "../auth"; + +// An echo route mounted on the same seam the OSS org routes use, so the +// resolved AuthContext (scope, role) is observable. `/v1/agents` below is the +// real project route; this only adds visibility. +const echoRoutes = (app: Hono) => { + app.get("/echo/project", auth(), (c) => c.json(c.get("auth"))); + app.get("/echo/org", auth({ requireProject: false }), (c) => + c.json(c.get("auth")), + ); +}; + +const nullSession = { getSession: async () => null }; + +const app = createApiApp(nullSession, { + roleResolver: ossRoleResolver, + eeRoutes: echoRoutes, +}); + +const bearer = (key: string, extra: Record = {}) => ({ + headers: { Authorization: `Bearer ${key}`, ...extra }, +}); + +interface EchoBody { + userId: string; + projectId?: string; + organizationId: string; + scope?: string; +} + +interface ErrorBody { + error: { message: string; type: string }; +} + +beforeEach(() => { + state.member = { role: "owner", status: "active" }; + state.hasBinding = false; + initStrictApiKeyAuth(false); +}); + +describe("project API key under rbac", () => { + it("authenticates the org OWNER with NO binding row (legacy-instance guard)", async () => { + // The single-user OSS instance whose project predates ProjectAccess + // bindings. If this ever fails, the rbac flip has bricked existing installs. + state.member = { role: "owner", status: "active" }; + state.hasBinding = false; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(200); + + const echo = await app.request("/v1/echo/project", bearer(PROJECT_KEY)); + const body = (await echo.json()) as EchoBody; + expect(body).toMatchObject({ + userId: USER, + projectId: PROJECT, + organizationId: ORG, + scope: "project", + }); + }); + + it("authenticates an ADMIN with no binding (admins reach any project in the org)", async () => { + state.member = { role: "admin", status: "active" }; + state.hasBinding = false; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(200); + }); + + it("rejects a plain MEMBER with no binding", async () => { + state.member = { role: "member", status: "active" }; + state.hasBinding = false; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(401); + }); + + it("authenticates a plain MEMBER who holds a binding", async () => { + state.member = { role: "member", status: "active" }; + state.hasBinding = true; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(200); + }); + + it.each(["owner", "admin", "member"])( + "rejects a SUSPENDED %s even with a binding (suspension invariant)", + async (role) => { + state.member = { role, status: "suspended" }; + state.hasBinding = true; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(401); + }, + ); + + it("rejects a user with no membership row at all", async () => { + state.member = null; + state.hasBinding = true; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(401); + }); + + it("rejects an unrecognized role without throwing (401, never 500)", async () => { + state.member = { role: "superadmin", status: "active" }; + state.hasBinding = true; + + const res = await app.request("/v1/agents", bearer(PROJECT_KEY)); + expect(res.status).toBe(401); + }); + + it("401s an unknown key with no session behind it (not a 500)", async () => { + const res = await app.request("/v1/agents", bearer("oc_nope")); + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorBody; + expect(body.error.type).toBe("authentication_error"); + }); +}); + +describe("org API key under rbac", () => { + it.each(["owner", "admin"])("authenticates an active %s", async (role) => { + state.member = { role, status: "active" }; + + const res = await app.request("/v1/echo/org", bearer(ORG_KEY)); + expect(res.status).toBe(200); + const body = (await res.json()) as EchoBody; + expect(body).toMatchObject({ organizationId: ORG, scope: "organization" }); + }); + + it("rejects a plain MEMBER (org keys are an admin capability)", async () => { + state.member = { role: "member", status: "active" }; + + const res = await app.request("/v1/echo/org", bearer(ORG_KEY)); + expect(res.status).toBe(401); + }); + + it("rejects a SUSPENDED admin", async () => { + state.member = { role: "admin", status: "suspended" }; + + const res = await app.request("/v1/echo/org", bearer(ORG_KEY)); + expect(res.status).toBe(401); + }); + + it("scopes a header-named project inside the key's org", async () => { + state.member = { role: "admin", status: "active" }; + + const res = await app.request( + "/v1/echo/org", + bearer(ORG_KEY, { "x-project-id": PROJECT }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as EchoBody; + expect(body.projectId).toBe(PROJECT); + }); + + it("rejects a header project outside the key's org", async () => { + state.member = { role: "admin", status: "active" }; + + const res = await app.request( + "/v1/echo/org", + bearer(ORG_KEY, { "x-project-id": OTHER_PROJECT }), + ); + expect(res.status).toBe(401); + }); + + // Strict mode makes the two rejection reasons distinguishable, which is the + // only way to assert their ORDER: the admin re-check must run before the + // missing-header complaint, so a demoted key holder is never told "just add a + // project header". + describe("strict API-key mode — sentinel ordering", () => { + beforeEach(() => initStrictApiKeyAuth(true)); + + it("asks an ADMIN for X-Project-Id on a project-scoped route", async () => { + state.member = { role: "admin", status: "active" }; + + const res = await app.request("/v1/agents", bearer(ORG_KEY)); + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorBody; + expect(body.error.message).toMatch(/X-Project-Id/); + }); + + it("tells a MEMBER the key is invalid, NOT to add a project header", async () => { + state.member = { role: "member", status: "active" }; + + const res = await app.request("/v1/agents", bearer(ORG_KEY)); + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorBody; + expect(body.error.message).not.toMatch(/X-Project-Id/); + }); + }); +}); diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts new file mode 100644 index 00000000..1cbcbff7 --- /dev/null +++ b/packages/api/src/routes/org/index.ts @@ -0,0 +1,24 @@ +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; +import { orgMemberRoutes } from "./members"; +import { orgInvitationRoutes } from "./invitations"; + +/** + * The OSS edition's `/v1/org/*` surface. + * + * OSS-ONLY BY CONSTRUCTION. This is never registered in the shared + * `createApiApp` route table: it is mounted through + * `CreateApiAppOptions.eeRoutes` from the OSS init seam + * (`apps/web/src/lib/init/api.ts`), which every EE edition aliases away and + * replaces with its own org router. Registering here rather than in `app.ts` + * keeps the shared file free of edition-specific routes (upstream-merge + * collisions) and avoids Hono's first-registration-wins silently shadowing an + * EE route with an OSS one. + * + * Later org slices (invitations, groups, role mappings) append their + * `app.route(...)` line here. + */ +export const registerOssOrgRoutes = (app: Hono) => { + app.route("/org/members", orgMemberRoutes()); + app.route("/org/invitations", orgInvitationRoutes()); +}; diff --git a/packages/api/src/routes/org/invitations.test.ts b/packages/api/src/routes/org/invitations.test.ts new file mode 100644 index 00000000..528b73d4 --- /dev/null +++ b/packages/api/src/routes/org/invitations.test.ts @@ -0,0 +1,784 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/invitations` end-to-end through the real app: the OSS org routes +// mounted on the `eeRoutes` seam, the OSS role resolver wired as the +// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key +// (whose key path re-checks admin through the resolver); the non-admin cases +// use a session, since a non-admin's org key fails key authentication +// outright. (Same harness as members.test.ts.) + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface InvitationRow { + id: string; + organizationId: string; + email: string; + role: string; + token: string; + status: string; + invitedById: string; + invitedByEmail: string; + expiresAt: Date; + createdAt: Date; +} + +interface AuditRow { + organizationId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + invitations: [] as InvitationRow[], + audits: [] as AuditRow[], + seq: 0, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + // The subset of the Prisma `where` shapes these routes actually build. + interface InvitationWhere { + id?: string; + organizationId?: string; + status?: string; + email?: { contains: string }; + /** The keyset predicate — the service nests it under AND, never top-level. */ + AND?: { + OR: { createdAt?: Date | { gt?: Date }; id?: { gt: string } }[]; + }[]; + } + interface InvitationKey { + organizationId_email?: { organizationId: string; email: string }; + token?: string; + } + interface MemberKey { + organizationId_userId: { organizationId: string; userId: string }; + } + + const matchesCursor = ( + row: InvitationRow, + filter: InvitationWhere["AND"], + ) => { + if (!filter) return true; + return filter.every((conjunct) => + conjunct.OR.some((clause) => { + if (clause.createdAt instanceof Date) { + return ( + row.createdAt.getTime() === clause.createdAt.getTime() && + clause.id !== undefined && + row.id > clause.id.gt + ); + } + const gt = clause.createdAt?.gt; + return gt !== undefined && row.createdAt.getTime() > gt.getTime(); + }), + ); + }; + + const filterInvitations = (where: InvitationWhere) => + store.invitations.filter( + (row) => + (where.id === undefined || row.id === where.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.status === undefined || row.status === where.status) && + (where.email === undefined || + row.email + .toLowerCase() + .includes(where.email.contains.toLowerCase())) && + matchesCursor(row, where.AND), + ); + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (row) => row.organizationId === organizationId && row.userId === userId, + ); + + return { + Prisma: { JsonNull: null }, + db: { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ where }: { where: MemberKey }) => { + const { organizationId, userId } = where.organizationId_userId; + return findMember(organizationId, userId) ?? null; + }, + // The session auth path resolves membership through these — a stub + // returning null would read every session caller as org-less (401). + findFirst: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: string; + status?: string | { not?: string }; + }; + }) => + store.members.find( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.status === undefined || + (typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || + row.status !== where.status.not)), + ) ?? null, + findMany: async () => [], + count: async () => 0, + }, + invitation: { + findUnique: async ({ where }: { where: InvitationKey }) => { + if (where.organizationId_email) { + const { organizationId, email } = where.organizationId_email; + return ( + store.invitations.find( + (row) => + row.organizationId === organizationId && row.email === email, + ) ?? null + ); + } + if (where.token !== undefined) { + return ( + store.invitations.find((row) => row.token === where.token) ?? null + ); + } + return null; + }, + // Mirror Prisma's `select` so a route can't accidentally leak a + // column the service didn't ask for. + findFirst: async ({ + where, + select, + }: { + where: InvitationWhere; + select?: Partial>; + }) => { + const row = filterInvitations(where)[0]; + if (!row) return null; + if (!select) return row; + const picked: Record = {}; + for (const key of Object.keys(select) as (keyof InvitationRow)[]) { + if (select[key]) picked[key] = row[key]; + } + return picked; + }, + findMany: async ({ + where, + take, + }: { + where: InvitationWhere; + take?: number; + }) => { + const rows = filterInvitations(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + return take === undefined ? rows : rows.slice(0, take); + }, + count: async ({ where }: { where: InvitationWhere }) => + filterInvitations(where).length, + upsert: async ({ + where, + create, + update, + }: { + where: InvitationKey; + create: Omit; + update: Partial; + }) => { + const key = where.organizationId_email; + if (!key) throw new Error("unexpected upsert key"); + const existing = store.invitations.find( + (row) => + row.organizationId === key.organizationId && + row.email === key.email, + ); + if (existing) { + Object.assign(existing, update); + return existing; + } + const row: InvitationRow = { + id: `inv-${++store.seq}`, + createdAt: new Date(), + ...create, + }; + store.invitations.push(row); + return row; + }, + updateMany: async ({ + where, + data, + }: { + where: InvitationWhere; + data: { status: string }; + }) => { + const rows = filterInvitations(where); + for (const row of rows) row.status = data.status; + return { count: rows.length }; + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); +const daysFromNow = (days: number) => + new Date(Date.now() + days * 24 * 60 * 60 * 1000); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +const invitation = ( + id: string, + email: string, + overrides: Partial = {}, +): InvitationRow => ({ + id, + organizationId: ORG, + email, + role: "member", + token: `inv_token-${id}`, + status: "pending", + invitedById: ADMIN, + invitedByEmail: "admin@example.com", + expiresAt: daysFromNow(5), + createdAt: at(10), + ...overrides, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@elsewhere.test", + name: null, + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member("user-outsider", "admin", at(3), OTHER_ORG), + ]; + store.invitations = [ + invitation("inv-a", "alpha@example.com", { createdAt: at(10) }), + invitation("inv-b", "beta@example.com", { + createdAt: at(11), + role: "admin", + }), + // An invitation in a DIFFERENT org — never visible through this org's routes. + invitation("inv-x", "outsider@example.com", { + organizationId: OTHER_ORG, + createdAt: at(12), + }), + ]; + store.audits = []; + store.seq = 100; + store.sessionUserId = null; +}); + +const rowFor = (id: string) => store.invitations.find((row) => row.id === id); +const rowForEmail = (email: string) => + store.invitations.find( + (row) => row.organizationId === ORG && row.email === email, + ); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request("/v1/org/invitations", { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const revoke = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/invitations/${id}`, { ...init, method: "DELETE" }); + +interface InvitationListBody { + data: { + id: string; + email: string; + role: string; + status: string; + invitedByEmail: string; + expiresAt: string; + createdAt: string; + token: string; + }[]; + nextCursor: string | null; +} + +const list = async (query = ""): Promise => { + const res = await app.request(`/v1/org/invitations${query}`, asAdmin); + expect(res.status).toBe(200); + return (await res.json()) as InvitationListBody; +}; + +describe("GET /v1/org/invitations", () => { + it("returns the org's invitations in the page envelope", async () => { + const body = await list(); + expect(body.nextCursor).toBeNull(); + expect(body.data.map((row) => row.id)).toEqual(["inv-a", "inv-b"]); + const first = rowFor("inv-a"); + expect(body.data[0]).toEqual({ + id: "inv-a", + email: "alpha@example.com", + role: "member", + status: "pending", + invitedByEmail: "admin@example.com", + expiresAt: first?.expiresAt.toISOString(), + createdAt: at(10).toISOString(), + token: "inv_token-inv-a", + }); + }); + + it("never leaks invitations of another organization", async () => { + const body = await list(); + expect(body.data.some((row) => row.id === "inv-x")).toBe(false); + }); + + it("filters by STORED status", async () => { + const row = rowFor("inv-b"); + if (row) row.status = "cancelled"; + const body = await list("?status=cancelled"); + expect(body.data.map((r) => r.id)).toEqual(["inv-b"]); + }); + + it("filters by free-text q over email", async () => { + const body = await list("?q=BETA"); + expect(body.data.map((r) => r.id)).toEqual(["inv-b"]); + }); + + it('projects a pending row past its expiry as "expired" without a write', async () => { + const row = rowFor("inv-a"); + if (row) row.expiresAt = new Date(Date.now() - 1000); + const body = await list(); + expect(body.data.find((r) => r.id === "inv-a")?.status).toBe("expired"); + // Display-only: the stored status is untouched. + expect(rowFor("inv-a")?.status).toBe("pending"); + }); + + it("pages with an opaque cursor and ends with nextCursor null", async () => { + const first = await list("?limit=1"); + expect(first.data.map((r) => r.id)).toEqual(["inv-a"]); + expect(first.nextCursor).toBeTruthy(); + + const second = await list( + `?limit=1&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.id)).toEqual(["inv-b"]); + expect(second.nextCursor).toBeNull(); + }); + + it("treats a malformed cursor as the first page instead of failing", async () => { + const body = await list("?cursor=not-a-real-cursor"); + expect(body.data).toHaveLength(2); + }); + + it("walks every page exactly once when createdAt ties", async () => { + // Same millisecond for all: only the id half of the cursor can separate + // them, so a one-at-a-time walk is the tiebreak's real test. + store.invitations.push(invitation("inv-c", "gamma@example.com")); + for (const row of store.invitations) row.createdAt = at(7); + + const seen: string[] = []; + let cursor: string | null = null; + for (let page = 0; page < 10; page++) { + const body: InvitationListBody = await list( + `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + seen.push(...body.data.map((r) => r.id)); + cursor = body.nextCursor; + if (!cursor) break; + } + + expect(cursor).toBeNull(); + expect(seen.slice().sort()).toEqual(["inv-a", "inv-b", "inv-c"]); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("rejects an out-of-range limit with 422", async () => { + const res = await app.request("/v1/org/invitations?limit=5000", asAdmin); + expect(res.status).toBe(422); + }); + + it("rejects limit=0 with 422", async () => { + const res = await app.request("/v1/org/invitations?limit=0", asAdmin); + expect(res.status).toBe(422); + }); + + it("rejects a non-numeric limit with 422", async () => { + const res = await app.request("/v1/org/invitations?limit=abc", asAdmin); + expect(res.status).toBe(422); + }); + + it("403s a project-scoped key even when its user is an org owner", async () => { + const res = await app.request("/v1/org/invitations", asProjectKey); + expect(res.status).toBe(403); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request("/v1/org/invitations"); + expect(res.status).toBe(403); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + const res = await app.request("/v1/org/invitations", asAdmin); + expect(res.status).toBe(401); + }); + + it("401s an unauthenticated caller", async () => { + const res = await app.request("/v1/org/invitations"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /v1/org/invitations", () => { + it("creates an invitation and returns the full row including the token", async () => { + const res = await create({ email: "new@example.com", role: "member" }); + expect(res.status).toBe(200); + const body = (await res.json()) as InvitationListBody["data"][number]; + expect(body).toMatchObject({ + email: "new@example.com", + role: "member", + status: "pending", + invitedByEmail: "admin@example.com", + }); + expect(body.token).toMatch(/^inv_[0-9a-f]{64}$/); + // ~7-day TTL. + const ttlMs = new Date(body.expiresAt).getTime() - Date.now(); + expect(ttlMs).toBeGreaterThan(6.9 * 24 * 60 * 60 * 1000); + expect(ttlMs).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000); + expect(rowForEmail("new@example.com")?.status).toBe("pending"); + }); + + it("normalizes the address (trim + lowercase) before storing", async () => { + const res = await create({ + email: " New.User@Example.COM ", + role: "member", + }); + expect(res.status).toBe(200); + expect(rowForEmail("new.user@example.com")).toBeTruthy(); + }); + + it("writes an audit row WITHOUT the token", async () => { + const res = await create({ email: "new@example.com", role: "admin" }); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "create", + service: "invitation", + source: "api", + metadata: { email: "new@example.com", role: "admin" }, + }); + expect(store.audits[0]?.metadata).toHaveProperty("invitationId"); + // The token is a bearer secret: never in the audit trail. + expect(store.audits[0]?.metadata).not.toHaveProperty("token"); + expect(JSON.stringify(store.audits[0])).not.toContain("inv_"); + }); + + it("422s a malformed email", async () => { + const res = await create({ email: "not-an-email", role: "member" }); + expect(res.status).toBe(422); + }); + + it("422s a missing role", async () => { + const res = await create({ email: "new@example.com" }); + expect(res.status).toBe(422); + }); + + it("422s the unassignable owner role", async () => { + const res = await create({ email: "new@example.com", role: "owner" }); + expect(res.status).toBe(422); + }); + + it("422s a missing/unparseable body", async () => { + const res = await app.request("/v1/org/invitations", { + ...asAdmin, + method: "POST", + }); + expect(res.status).toBe(422); + }); + + it("400s a self-invite", async () => { + const res = await create({ email: "admin@example.com", role: "member" }); + expect(res.status).toBe(400); + expect(store.audits).toHaveLength(0); + }); + + it("409s an address that is already an active member", async () => { + const res = await create({ + email: "member@elsewhere.test", + role: "member", + }); + expect(res.status).toBe(409); + }); + + it("409s a suspended member's address with the distinct reinstate message", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + const res = await create({ + email: "member@elsewhere.test", + role: "member", + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: { message: string } }; + expect(JSON.stringify(body)).toContain("reinstate"); + }); + + it("409s when a pending, unexpired invitation already exists", async () => { + const res = await create({ email: "alpha@example.com", role: "member" }); + expect(res.status).toBe(409); + // The existing row is untouched (same token). + expect(rowForEmail("alpha@example.com")?.token).toBe("inv_token-inv-a"); + }); + + it("409s at the pending-invitation cap", async () => { + for (let i = 0; i < 100; i++) { + store.invitations.push( + invitation(`inv-cap-${i}`, `cap-${i}@example.com`), + ); + } + const res = await create({ email: "new@example.com", role: "member" }); + expect(res.status).toBe(409); + }); + + it("OVERWRITES a cancelled row: fresh token, pending again", async () => { + const row = rowForEmail("alpha@example.com"); + if (row) row.status = "cancelled"; + const res = await create({ email: "alpha@example.com", role: "admin" }); + expect(res.status).toBe(200); + const updated = rowForEmail("alpha@example.com"); + expect(updated?.status).toBe("pending"); + expect(updated?.role).toBe("admin"); + expect(updated?.token).not.toBe("inv_token-inv-a"); + }); + + it("OVERWRITES an accepted row: fresh token, pending again", async () => { + const row = rowForEmail("alpha@example.com"); + if (row) row.status = "accepted"; + const res = await create({ email: "alpha@example.com", role: "member" }); + expect(res.status).toBe(200); + expect(rowForEmail("alpha@example.com")?.status).toBe("pending"); + expect(rowForEmail("alpha@example.com")?.token).not.toBe("inv_token-inv-a"); + }); + + it("OVERWRITES an expired pending row: fresh token and expiry", async () => { + const row = rowForEmail("alpha@example.com"); + if (row) row.expiresAt = new Date(Date.now() - 1000); + const res = await create({ email: "alpha@example.com", role: "member" }); + expect(res.status).toBe(200); + const updated = rowForEmail("alpha@example.com"); + expect(updated?.status).toBe("pending"); + expect(updated?.token).not.toBe("inv_token-inv-a"); + expect(updated && updated.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it("403s a project-scoped key and audits nothing", async () => { + const res = await create( + { email: "new@example.com", role: "member" }, + asProjectKey, + ); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + expect(rowForEmail("new@example.com")).toBeUndefined(); + }); + + it("403s a non-admin member and audits nothing", async () => { + store.sessionUserId = MEMBER; + const res = await create({ email: "new@example.com", role: "member" }, {}); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("DELETE /v1/org/invitations/:id", () => { + it("revokes a pending invitation (status → cancelled) and audits it", async () => { + const res = await revoke("inv-a"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "inv-a", + email: "alpha@example.com", + }); + expect(rowFor("inv-a")?.status).toBe("cancelled"); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "delete", + service: "invitation", + source: "api", + metadata: { invitationId: "inv-a", email: "alpha@example.com" }, + }); + expect(store.audits[0]?.metadata).not.toHaveProperty("token"); + }); + + it("404s an unknown id and audits nothing", async () => { + const res = await revoke("inv-nope"); + expect(res.status).toBe(404); + expect(store.audits).toHaveLength(0); + }); + + it("404s an invitation of another organization (cross-org isolation)", async () => { + const res = await revoke("inv-x"); + expect(res.status).toBe(404); + expect(rowFor("inv-x")?.status).toBe("pending"); + }); + + it("409s a non-pending invitation", async () => { + const row = rowFor("inv-a"); + if (row) row.status = "cancelled"; + const res = await revoke("inv-a"); + expect(res.status).toBe(409); + }); + + it("403s a project-scoped key attempting a revoke", async () => { + const res = await revoke("inv-a", asProjectKey); + expect(res.status).toBe(403); + expect(rowFor("inv-a")?.status).toBe("pending"); + expect(store.audits).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/invitations.ts b/packages/api/src/routes/org/invitations.ts new file mode 100644 index 00000000..7c1ab499 --- /dev/null +++ b/packages/api/src/routes/org/invitations.ts @@ -0,0 +1,116 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + createOrgInvitation, + listOrgInvitations, + revokeOrgInvitation, +} from "../../services/org-invitation-service"; +import { + createInvitationSchema, + invitationListQuerySchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/invitations` — link-based invitations to the organization. + * + * Same guard stack as `/v1/org/members`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). Both only work because the OSS + * edition now registers a `RoleResolver`. + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. A + * leaked agent key would then be able to mint invitation links into the org. + * Org-wide authority requires an org-wide credential, so project-scoped + * callers are rejected outright. + * + * There is deliberately NO accept route here (D-G): accepting runs through a + * Server Action, because a brand-new invitee has a session but no DB user row + * and the standard auth middleware cannot authenticate them. + */ +export const orgInvitationRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // GET /org/invitations — cursor-paged, optionally filtered by status / email. + app.get("/", async (c) => { + const auth = c.get("auth"); + const query = parse(invitationListQuerySchema, c.req.query()); + return c.json(await listOrgInvitations(auth.organizationId, query)); + }); + + // POST /org/invitations — mint (or re-issue) an invitation link. + app.post("/", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(createInvitationSchema, body); + + // `organizationId` in the audit params is deliberate: besides scoping the + // audit row it flushes the gateway's org cache. The metadata NEVER carries + // the token — an audit row must not be a second channel for the secret. + const invitation = await withAudit( + () => + createOrgInvitation( + auth.organizationId, + auth.userId, + auth.userEmail, + input, + ), + (inv) => ({ + organizationId: auth.organizationId, + userId: auth.userId, + userEmail: auth.userEmail, + action: AUDIT_ACTIONS.CREATE, + service: AUDIT_SERVICES.INVITATION, + source: AUDIT_SOURCE.API, + metadata: { invitationId: inv.id, email: inv.email, role: inv.role }, + }), + ); + return c.json(invitation); + }); + + // DELETE /org/invitations/:id — revoke a pending invitation. + app.delete("/:id", async (c) => { + const auth = c.get("auth"); + const invitationId = c.req.param("id"); + + const revoked = await withAudit( + () => revokeOrgInvitation(auth.organizationId, invitationId), + (r) => ({ + organizationId: auth.organizationId, + userId: auth.userId, + userEmail: auth.userEmail, + action: AUDIT_ACTIONS.DELETE, + service: AUDIT_SERVICES.INVITATION, + source: AUDIT_SOURCE.API, + metadata: { invitationId: r.id, email: r.email }, + }), + ); + return c.json(revoked); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/members.test.ts b/packages/api/src/routes/org/members.test.ts new file mode 100644 index 00000000..05400919 --- /dev/null +++ b/packages/api/src/routes/org/members.test.ts @@ -0,0 +1,655 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/members` end-to-end through the real app: the OSS org routes mounted +// on the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and +// `CAPS.rbac` on. Admin callers arrive with an org API key (whose key path +// re-checks admin through the resolver); the non-admin cases use a session, +// since a non-admin's org key fails key authentication outright. + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface AuditRow { + organizationId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: unknown; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + audits: [] as AuditRow[], + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + // The subset of the Prisma `where` shapes these routes actually build. + interface MemberWhere { + organizationId?: string; + userId?: string; + role?: string; + status?: string | { not?: string }; + user?: { + OR: { email?: { contains: string }; name?: { contains: string } }[]; + }; + /** The keyset predicate — the service nests it under AND, never top-level. */ + AND?: { + OR: { createdAt?: Date | { gt?: Date }; userId?: { gt: string } }[]; + }[]; + } + interface MemberSelect { + userId?: boolean; + role?: boolean; + status?: boolean; + ssoExempt?: boolean; + } + interface MemberKey { + organizationId_userId: { organizationId: string; userId: string }; + } + + const matchesStatus = ( + row: MemberRow, + filter: MemberWhere["status"], + ): boolean => { + if (filter === undefined) return true; + if (typeof filter === "string") return row.status === filter; + return filter.not === undefined ? true : row.status !== filter.not; + }; + + const matchesQuery = (row: MemberRow, filter: MemberWhere["user"]) => { + if (!filter) return true; + const user = store.users.find((u) => u.id === row.userId); + if (!user) return false; + return filter.OR.some((clause) => { + const needle = clause.email?.contains ?? clause.name?.contains; + if (needle === undefined) return false; + const haystack = clause.email ? user.email : (user.name ?? ""); + return haystack.toLowerCase().includes(needle.toLowerCase()); + }); + }; + + const matchesCursor = (row: MemberRow, filter: MemberWhere["AND"]) => { + if (!filter) return true; + return filter.every((conjunct) => + conjunct.OR.some((clause) => { + if (clause.createdAt instanceof Date) { + return ( + row.createdAt.getTime() === clause.createdAt.getTime() && + clause.userId !== undefined && + row.userId > clause.userId.gt + ); + } + const gt = clause.createdAt?.gt; + return gt !== undefined && row.createdAt.getTime() > gt.getTime(); + }), + ); + }; + + const filterMembers = (where: MemberWhere) => + store.members.filter( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.role === undefined || row.role === where.role) && + matchesStatus(row, where.status) && + matchesQuery(row, where.user) && + matchesCursor(row, where.AND), + ); + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (row) => row.organizationId === organizationId && row.userId === userId, + ); + + // Mirror Prisma's `select` so a route can't accidentally leak a column the + // service didn't ask for (the mock would otherwise hand back whole rows). + const project = (row: MemberRow, select?: MemberSelect) => { + if (!select) return row; + const picked: Record = {}; + if (select.userId) picked.userId = row.userId; + if (select.role) picked.role = row.role; + if (select.status) picked.status = row.status; + if (select.ssoExempt) picked.ssoExempt = row.ssoExempt; + return picked; + }; + + return { + Prisma: { JsonNull: null }, + db: { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + select, + }: { + where: MemberKey; + select?: MemberSelect; + }) => { + const { organizationId, userId } = where.organizationId_userId; + const row = findMember(organizationId, userId); + return row ? project(row, select) : null; + }, + findFirst: async ({ where }: { where: MemberWhere }) => + filterMembers(where)[0] ?? null, + findMany: async ({ + where, + take, + }: { + where: MemberWhere; + take?: number; + }) => { + const rows = filterMembers(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.userId.localeCompare(b.userId), + ) + .map((row) => ({ + ...row, + user: store.users.find((u) => u.id === row.userId) ?? { + email: "", + name: null, + }, + })); + return take === undefined ? rows : rows.slice(0, take); + }, + count: async ({ where }: { where: MemberWhere }) => + filterMembers(where).length, + update: async ({ + where, + data, + select, + }: { + where: MemberKey; + data: { status?: string; role?: string; suspendedAt?: Date | null }; + select?: MemberSelect; + }) => { + const { organizationId, userId } = where.organizationId_userId; + const row = findMember(organizationId, userId); + if (!row) throw new Error("no such member"); + if (data.status !== undefined) row.status = data.status; + if (data.role !== undefined) row.role = data.role; + if (data.suspendedAt !== undefined) + row.suspendedAt = data.suspendedAt; + return project(row, select); + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@elsewhere.test", + name: null, + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + // A member of a DIFFERENT org — never visible through this org's routes. + member("user-outsider", "admin", at(3), OTHER_ORG), + ]; + store.audits = []; + store.sessionUserId = null; +}); + +const rowFor = (userId: string) => + store.members.find((m) => m.userId === userId); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { + headers: { Authorization: `Bearer ${PROJECT_KEY}` }, +}; + +const patch = (userId: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/members/${userId}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +interface MemberListBody { + data: { + userId: string; + email: string; + name: string | null; + role: string; + status: string; + ssoExempt: boolean; + joinedAt: string; + }[]; + nextCursor: string | null; +} + +const list = async (query = ""): Promise => { + const res = await app.request(`/v1/org/members${query}`, asAdmin); + expect(res.status).toBe(200); + return (await res.json()) as MemberListBody; +}; + +describe("GET /v1/org/members", () => { + it("returns the org's members in the page envelope", async () => { + const body = await list(); + expect(body.nextCursor).toBeNull(); + expect(body.data.map((row) => row.userId)).toEqual([OWNER, ADMIN, MEMBER]); + expect(body.data[0]).toEqual({ + userId: OWNER, + email: "owner@example.com", + name: "Olive Owner", + role: "owner", + status: "active", + ssoExempt: false, + joinedAt: at(0).toISOString(), + }); + }); + + it("never leaks members of another organization", async () => { + const body = await list(); + expect(body.data.some((row) => row.userId === "user-outsider")).toBe(false); + }); + + it("filters by status", async () => { + const row = rowFor(MEMBER); + if (row) row.status = "suspended"; + const body = await list("?status=suspended"); + expect(body.data.map((r) => r.userId)).toEqual([MEMBER]); + }); + + it("filters by free-text q over email and name", async () => { + expect((await list("?q=ELSEWHERE")).data.map((r) => r.userId)).toEqual([ + MEMBER, + ]); + expect((await list("?q=olive")).data.map((r) => r.userId)).toEqual([OWNER]); + }); + + it("pages with an opaque cursor and ends with nextCursor null", async () => { + const first = await list("?limit=2"); + expect(first.data.map((r) => r.userId)).toEqual([OWNER, ADMIN]); + expect(first.nextCursor).toBeTruthy(); + + const second = await list( + `?limit=2&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.userId)).toEqual([MEMBER]); + expect(second.nextCursor).toBeNull(); + }); + + it("treats a malformed cursor as the first page instead of failing", async () => { + const body = await list("?cursor=not-a-real-cursor"); + expect(body.data).toHaveLength(3); + }); + + it("walks every page exactly once when createdAt ties", async () => { + // Same millisecond for all three: only the userId half of the cursor can + // separate them, so a one-at-a-time walk is the tiebreak's real test. + for (const row of store.members) row.createdAt = at(7); + + const seen: string[] = []; + let cursor: string | null = null; + for (let page = 0; page < 10; page++) { + const body: MemberListBody = await list( + `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + seen.push(...body.data.map((r) => r.userId)); + cursor = body.nextCursor; + if (!cursor) break; + } + + expect(cursor).toBeNull(); + expect(seen.slice().sort()).toEqual([ADMIN, MEMBER, OWNER].slice().sort()); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("rejects an out-of-range limit with 422", async () => { + const res = await app.request("/v1/org/members?limit=5000", asAdmin); + expect(res.status).toBe(422); + }); + + it("rejects limit=0 with 422", async () => { + const res = await app.request("/v1/org/members?limit=0", asAdmin); + expect(res.status).toBe(422); + }); + + it("rejects a non-numeric limit with 422", async () => { + const res = await app.request("/v1/org/members?limit=abc", asAdmin); + expect(res.status).toBe(422); + }); + + it("403s a project-scoped key even when its user is an org owner", async () => { + // A leaked agent/project key must never become an org-management + // credential: `role: "admin"` alone would pass here. + const res = await app.request("/v1/org/members", asProjectKey); + expect(res.status).toBe(403); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request("/v1/org/members"); + expect(res.status).toBe(403); + }); + + it("200s an owner (owner outranks admin)", async () => { + store.sessionUserId = OWNER; + const res = await app.request("/v1/org/members"); + expect(res.status).toBe(200); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = rowFor(ADMIN); + if (row) row.status = "suspended"; + const res = await app.request("/v1/org/members", asAdmin); + expect(res.status).toBe(401); + }); + + it("401s an unauthenticated caller", async () => { + const res = await app.request("/v1/org/members"); + expect(res.status).toBe(401); + }); +}); + +describe("PATCH /v1/org/members/:userId — status", () => { + it("suspends a member and stamps suspendedAt", async () => { + const res = await patch(MEMBER, { status: "suspended" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + userId: MEMBER, + status: "suspended", + ssoExempt: false, + }); + expect(rowFor(MEMBER)?.status).toBe("suspended"); + expect(rowFor(MEMBER)?.suspendedAt).toBeInstanceOf(Date); + }); + + it("reinstates a suspended member and clears suspendedAt", async () => { + const row = rowFor(MEMBER); + if (row) { + row.status = "suspended"; + row.suspendedAt = at(9); + } + const res = await patch(MEMBER, { status: "active" }); + expect(res.status).toBe(200); + expect(rowFor(MEMBER)?.status).toBe("active"); + expect(rowFor(MEMBER)?.suspendedAt).toBeNull(); + }); + + it("writes an audit row for the change", async () => { + await patch(MEMBER, { status: "suspended" }); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "update", + service: "member", + source: "api", + metadata: { targetUserId: MEMBER, status: "suspended" }, + }); + }); + + it("404s an unknown target and audits nothing", async () => { + const res = await patch("user-nobody", { status: "suspended" }); + expect(res.status).toBe(404); + expect(store.audits).toHaveLength(0); + }); + + it("404s a member of another organization", async () => { + const res = await patch("user-outsider", { status: "suspended" }); + expect(res.status).toBe(404); + expect(rowFor("user-outsider")?.status).toBe("active"); + }); + + it("400s a self-suspend", async () => { + const res = await patch(ADMIN, { status: "suspended" }); + expect(res.status).toBe(400); + expect(rowFor(ADMIN)?.status).toBe("active"); + }); + + it("403s suspending an owner while another active owner remains", async () => { + store.members.push(member("user-owner-2", "owner", at(4))); + const res = await patch(OWNER, { status: "suspended" }); + expect(res.status).toBe(403); + expect(rowFor(OWNER)?.status).toBe("active"); + }); + + it("409s suspending the last active owner", async () => { + const res = await patch(OWNER, { status: "suspended" }); + expect(res.status).toBe(409); + expect(rowFor(OWNER)?.status).toBe("active"); + }); + + it("REINSTATES a suspended owner — the owner guard is suspend-only", async () => { + // The org's only owner is already suspended: refusing the repair would + // cement exactly the unrecoverable state the owner rules exist to prevent. + const owner = rowFor(OWNER); + if (owner) { + owner.status = "suspended"; + owner.suspendedAt = at(9); + } + const res = await patch(OWNER, { status: "active" }); + expect(res.status).toBe(200); + expect(rowFor(OWNER)?.status).toBe("active"); + expect(rowFor(OWNER)?.suspendedAt).toBeNull(); + }); + + it("400s an OWNER suspending themselves (not just admins)", async () => { + store.sessionUserId = OWNER; + const res = await patch(OWNER, { status: "suspended" }, {}); + expect(res.status).toBe(400); + expect(rowFor(OWNER)?.status).toBe("active"); + }); + + it("400s an OWNER changing their own role", async () => { + store.sessionUserId = OWNER; + const res = await patch(OWNER, { role: "admin" }, {}); + expect(res.status).toBe(400); + expect(rowFor(OWNER)?.role).toBe("owner"); + }); + + it("403s a project-scoped key attempting a write", async () => { + const res = await patch(MEMBER, { status: "suspended" }, asProjectKey); + expect(res.status).toBe(403); + expect(rowFor(MEMBER)?.status).toBe("active"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PATCH /v1/org/members/:userId — role", () => { + it("promotes a member to admin", async () => { + const res = await patch(MEMBER, { role: "admin" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ userId: MEMBER, role: "admin" }); + expect(rowFor(MEMBER)?.role).toBe("admin"); + expect(store.audits[0]).toMatchObject({ + metadata: { targetUserId: MEMBER, role: "admin" }, + }); + }); + + it("400s changing your own role", async () => { + const res = await patch(ADMIN, { role: "member" }); + expect(res.status).toBe(400); + expect(rowFor(ADMIN)?.role).toBe("admin"); + }); + + it("403s changing an owner's role", async () => { + const res = await patch(OWNER, { role: "admin" }); + expect(res.status).toBe(403); + expect(rowFor(OWNER)?.role).toBe("owner"); + }); + + it("422s an unassignable role", async () => { + const res = await patch(MEMBER, { role: "owner" }); + expect(res.status).toBe(422); + expect(rowFor(MEMBER)?.role).toBe("member"); + }); +}); + +describe("PATCH /v1/org/members/:userId — body validation", () => { + it("422s a body carrying both status and role", async () => { + const res = await patch(MEMBER, { status: "suspended", role: "admin" }); + expect(res.status).toBe(422); + expect(store.audits).toHaveLength(0); + }); + + it("422s an empty body", async () => { + const res = await patch(MEMBER, {}); + expect(res.status).toBe(422); + }); + + it("422s a missing/unparseable body", async () => { + const res = await app.request(`/v1/org/members/${MEMBER}`, { + ...asAdmin, + method: "PATCH", + }); + expect(res.status).toBe(422); + }); + + it("422s an unknown status value", async () => { + const res = await patch(MEMBER, { status: "deleted" }); + expect(res.status).toBe(422); + }); + + it("403s a non-admin attempting a write, before the service runs", async () => { + store.sessionUserId = MEMBER; + const res = await patch(OWNER, { status: "suspended" }, {}); + expect(res.status).toBe(403); + expect(rowFor(OWNER)?.status).toBe("active"); + expect(store.audits).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/members.ts b/packages/api/src/routes/org/members.ts new file mode 100644 index 00000000..db5ee4aa --- /dev/null +++ b/packages/api/src/routes/org/members.ts @@ -0,0 +1,112 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + listOrgMembers, + updateOrgMemberRole, + updateOrgMemberStatus, +} from "../../services/org-member-service"; +import { + orgMemberListQuerySchema, + updateOrgMemberSchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/members` — the organization's membership directory. + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). Both only work because the OSS + * edition now registers a `RoleResolver`. + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. A + * leaked agent key would then be able to manage org membership. Org-wide + * authority requires an org-wide credential, so project-scoped callers are + * rejected outright. + */ +export const orgMemberRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // GET /org/members — cursor-paged, optionally filtered by status / free text. + app.get("/", async (c) => { + const auth = c.get("auth"); + const query = parse(orgMemberListQuerySchema, c.req.query()); + return c.json(await listOrgMembers(auth.organizationId, query)); + }); + + // PATCH /org/members/:userId — exactly one of { status } | { role }. + app.patch("/:userId", async (c) => { + const auth = c.get("auth"); + const targetUserId = c.req.param("userId"); + const body = await c.req.json().catch(() => null); + const input = parse(updateOrgMemberSchema, body); + + // `organizationId` in the audit params is deliberate: besides scoping the + // audit row it flushes the gateway's org cache, which a membership change + // must invalidate. + const auditBase = { + organizationId: auth.organizationId, + userId: auth.userId, + userEmail: auth.userEmail, + action: AUDIT_ACTIONS.UPDATE, + service: AUDIT_SERVICES.MEMBER, + source: AUDIT_SOURCE.API, + }; + + if ("status" in input) { + const member = await withAudit( + () => + updateOrgMemberStatus( + auth.organizationId, + auth.userId, + targetUserId, + input.status, + ), + (updated) => ({ + ...auditBase, + metadata: { targetUserId, status: updated.status }, + }), + ); + return c.json(member); + } + + const member = await withAudit( + () => + updateOrgMemberRole( + auth.organizationId, + auth.userId, + targetUserId, + input.role, + ), + (updated) => ({ + ...auditBase, + metadata: { targetUserId, role: updated.role }, + }), + ); + return c.json(member); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/parse.ts b/packages/api/src/routes/org/parse.ts new file mode 100644 index 00000000..7f2b2018 --- /dev/null +++ b/packages/api/src/routes/org/parse.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ServiceError } from "../../services/errors"; + +/** Validate-or-throw: an invalid payload is a 422, never a hand-rolled 400. */ +export const parse = ( + schema: S, + input: unknown, +): z.infer => { + const result = schema.safeParse(input); + if (!result.success) { + throw new ServiceError( + "UNPROCESSABLE", + result.error.issues[0]?.message ?? "Invalid request body", + ); + } + return result.data; +}; diff --git a/packages/api/src/services/audit-service.ts b/packages/api/src/services/audit-service.ts index fa4427ce..c5db2cf5 100644 --- a/packages/api/src/services/audit-service.ts +++ b/packages/api/src/services/audit-service.ts @@ -51,8 +51,14 @@ export const AUDIT_SERVICES = { DOMAIN: "domain", // EE-only (identity): org SSO/IdP connections SSO_CONNECTION: "sso-connection", - // EE-only (identity): org membership rows (e.g. SSO JIT joins) + // Org membership rows: suspend/reinstate and role changes via + // `PATCH /v1/org/members/:userId` (OSS), plus EE identity writes (SSO JIT + // joins, SCIM deprovisioning) and the member role changes role mappings drive. MEMBER: "member", + // Link-based org invitations (create / revoke via `/v1/org/invitations`). + // ACCEPTANCE is deliberately not here: accepting creates a membership, so it + // audits as a MEMBER create with `via: "invitation"` metadata. + INVITATION: "invitation", // EE-only (directory): human groups (manual + SCIM-provisioned) GROUP: "group", // EE-only (directory): group→org-role mappings (the mapping config itself; diff --git a/packages/api/src/services/org-invitation-service.test.ts b/packages/api/src/services/org-invitation-service.test.ts new file mode 100644 index 00000000..f64fe1b8 --- /dev/null +++ b/packages/api/src/services/org-invitation-service.test.ts @@ -0,0 +1,536 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Accept-path invariants for the invitation service, exercised DIRECTLY (no +// HTTP): the accept surface is a Server Action, so these behaviors have no +// route test to live in. In-memory `@onecli/db` mock; the project provisioner +// (`ensureMemberDefaultProject`) is a spy — its own behavior is covered by +// organization-service.test.ts. + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface OrgRow { + id: string; + name: string; +} +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; +} +interface InvitationRow { + id: string; + organizationId: string; + email: string; + role: string; + token: string; + status: string; + invitedById: string; + invitedByEmail: string; + expiresAt: Date; + createdAt: Date; +} + +const store = vi.hoisted(() => ({ + orgs: [] as OrgRow[], + users: [] as UserRow[], + members: [] as MemberRow[], + invitations: [] as InvitationRow[], + seq: 0, +})); + +const ensureMemberDefaultProject = vi.hoisted(() => + vi.fn(async (organizationId: string) => ({ + id: `proj-of-${organizationId}`, + organizationId, + })), +); + +vi.mock("./organization-service", () => ({ + ensureMemberDefaultProject, +})); + +vi.mock("@onecli/db", () => { + interface InvitationWhere { + id?: string; + status?: string; + organizationId?: string; + } + + const filterInvitations = (where: InvitationWhere) => + store.invitations.filter( + (row) => + (where.id === undefined || row.id === where.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.status === undefined || row.status === where.status), + ); + + return { + Prisma: { JsonNull: null }, + db: { + invitation: { + findUnique: async ({ + where, + include, + }: { + where: { token?: string }; + include?: { organization?: unknown }; + }) => { + const row = store.invitations.find((r) => r.token === where.token); + if (!row) return null; + if (!include?.organization) return { ...row }; + const org = store.orgs.find((o) => o.id === row.organizationId); + return { ...row, organization: { name: org?.name ?? "?" } }; + }, + updateMany: async ({ + where, + data, + }: { + where: InvitationWhere; + data: { status: string }; + }) => { + const rows = filterInvitations(where); + for (const row of rows) row.status = data.status; + return { count: rows.length }; + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return ( + store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ) ?? null + ); + }, + findFirst: async ({ + where, + }: { + where: { userId: string; organizationId?: { not: string } }; + }) => + store.members.find( + (m) => + m.userId === where.userId && + (where.organizationId?.not === undefined || + m.organizationId !== where.organizationId.not), + ) ?? null, + upsert: async ({ + where, + create, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + create: Omit; + }) => { + const { organizationId, userId } = where.organizationId_userId; + const existing = store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ); + if (existing) return existing; + const row: MemberRow = { status: "active", ...create }; + store.members.push(row); + return row; + }, + }, + user: { + // Mirror Prisma's `select` so the service only sees what it asked for. + findUnique: async ({ + where, + select, + }: { + where: { externalAuthId?: string; email?: string }; + select?: Partial>; + }) => { + const row = store.users.find( + (u) => + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ); + if (!row) return null; + if (!select) return row; + const picked: Record = {}; + for (const key of Object.keys(select) as (keyof UserRow)[]) { + if (select[key]) picked[key] = row[key]; + } + return picked; + }, + create: async ({ + data, + }: { + data: { externalAuthId: string; email: string; name: string | null }; + }) => { + const row: UserRow = { + id: `user-${++store.seq}`, + externalAuthId: data.externalAuthId, + email: data.email, + name: data.name, + }; + store.users.push(row); + return row; + }, + }, + }, + }; +}); + +import { + acceptInvitation, + describeInvitation, + generateInvitationToken, + resolveInviteeUser, +} from "./org-invitation-service"; +import { IDENTITY_CONFLICT_ERROR } from "../routes/auth-session"; + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const TOKEN = "inv_test-token"; + +const daysFromNow = (days: number) => + new Date(Date.now() + days * 24 * 60 * 60 * 1000); + +const invitation = (overrides: Partial = {}): InvitationRow => ({ + id: "inv-1", + organizationId: ORG, + email: "dev@example.com", + role: "admin", + token: TOKEN, + status: "pending", + invitedById: "user-admin", + invitedByEmail: "admin@example.com", + expiresAt: daysFromNow(5), + createdAt: new Date(), + ...overrides, +}); + +beforeEach(() => { + store.orgs = [ + { id: ORG, name: "Acme" }, + { id: OTHER_ORG, name: "Umbrella" }, + ]; + store.users = [ + { + id: "user-dev", + externalAuthId: "ext-dev", + email: "dev@example.com", + name: "Dev", + }, + ]; + store.members = []; + store.invitations = [invitation()]; + store.seq = 0; + ensureMemberDefaultProject.mockClear(); +}); + +const membershipsOf = (userId: string) => + store.members.filter((m) => m.userId === userId); + +describe("acceptInvitation", () => { + it("creates the membership with the INVITED role and returns the ensured project", async () => { + const result = await acceptInvitation(TOKEN, "user-dev", "dev@example.com"); + + expect(result).toEqual({ + organizationId: ORG, + organizationName: "Acme", + projectId: `proj-of-${ORG}`, + role: "admin", + alreadyMember: false, + invitationId: "inv-1", + }); + expect(membershipsOf("user-dev")).toEqual([ + { + organizationId: ORG, + userId: "user-dev", + userEmail: "dev@example.com", + role: "admin", + status: "active", + }, + ]); + expect(store.invitations[0]?.status).toBe("accepted"); + // NOT best-effort: awaited, and its project id is what the caller returns. + expect(ensureMemberDefaultProject).toHaveBeenCalledExactlyOnceWith( + ORG, + "user-dev", + "dev@example.com", + ); + }); + + it("matches the invited email case-insensitively with surrounding whitespace", async () => { + const result = await acceptInvitation( + TOKEN, + "user-dev", + " Dev@Example.COM ", + ); + expect(result.alreadyMember).toBe(false); + expect(membershipsOf("user-dev")).toHaveLength(1); + }); + + it("410s an expired invitation and lazily writes the status back", async () => { + store.invitations[0]!.expiresAt = new Date(Date.now() - 1000); + await expect( + acceptInvitation(TOKEN, "user-dev", "dev@example.com"), + ).rejects.toMatchObject({ code: "GONE" }); + expect(store.invitations[0]?.status).toBe("expired"); + expect(membershipsOf("user-dev")).toHaveLength(0); + expect(ensureMemberDefaultProject).not.toHaveBeenCalled(); + }); + + it("410s a cancelled invitation", async () => { + store.invitations[0]!.status = "cancelled"; + await expect( + acceptInvitation(TOKEN, "user-dev", "dev@example.com"), + ).rejects.toMatchObject({ code: "GONE" }); + expect(store.invitations[0]?.status).toBe("cancelled"); + expect(membershipsOf("user-dev")).toHaveLength(0); + }); + + it("is single-use: the second accept 410s and exactly one membership exists", async () => { + await acceptInvitation(TOKEN, "user-dev", "dev@example.com"); + // A second, different session replaying the link (membership was removed + // out-of-band so the already-member arm can't answer). + store.members = []; + await expect( + acceptInvitation(TOKEN, "user-dev", "dev@example.com"), + ).rejects.toMatchObject({ code: "GONE" }); + expect(membershipsOf("user-dev")).toHaveLength(0); + }); + + it("403s an email mismatch WITHOUT burning the token", async () => { + await expect( + acceptInvitation(TOKEN, "user-dev", "someone-else@example.com"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + // The failed attempt must not consume the invitation or create state. + expect(store.invitations[0]?.status).toBe("pending"); + expect(membershipsOf("user-dev")).toHaveLength(0); + expect(ensureMemberDefaultProject).not.toHaveBeenCalled(); + }); + + it("creates the membership in the TOKEN'S org only", async () => { + store.invitations = [ + invitation({ organizationId: OTHER_ORG, token: "inv_other" }), + ]; + const result = await acceptInvitation( + "inv_other", + "user-dev", + "dev@example.com", + ); + expect(result.organizationId).toBe(OTHER_ORG); + expect(result.organizationName).toBe("Umbrella"); + expect(membershipsOf("user-dev").map((m) => m.organizationId)).toEqual([ + OTHER_ORG, + ]); + }); + + it("is idempotent for an already-active member (keeps their EXISTING role)", async () => { + store.members.push({ + organizationId: ORG, + userId: "user-dev", + userEmail: "dev@example.com", + role: "member", + status: "active", + }); + + const result = await acceptInvitation(TOKEN, "user-dev", "dev@example.com"); + + expect(result.alreadyMember).toBe(true); + // The invitation (role: admin) must NOT escalate an existing membership. + expect(result.role).toBe("member"); + expect(membershipsOf("user-dev")).toHaveLength(1); + expect(membershipsOf("user-dev")[0]?.role).toBe("member"); + expect(store.invitations[0]?.status).toBe("accepted"); + // The find-or-create still runs: the member needs a project to land on. + expect(ensureMemberDefaultProject).toHaveBeenCalledOnce(); + }); + + it("409s a suspended member and leaves everything untouched", async () => { + store.members.push({ + organizationId: ORG, + userId: "user-dev", + userEmail: "dev@example.com", + role: "member", + status: "suspended", + }); + + await expect( + acceptInvitation(TOKEN, "user-dev", "dev@example.com"), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(store.invitations[0]?.status).toBe("pending"); + expect(membershipsOf("user-dev")[0]?.status).toBe("suspended"); + expect(ensureMemberDefaultProject).not.toHaveBeenCalled(); + }); + + it("404s an unknown token with a generic message", async () => { + await expect( + acceptInvitation("inv_who-knows", "user-dev", "dev@example.com"), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Invitation not found.", + }); + }); +}); + +describe("resolveInviteeUser", () => { + it("creates the user row for a brand-new identity", async () => { + const user = await resolveInviteeUser("ext-new", "new@example.com", "New"); + expect(user.email).toBe("new@example.com"); + expect( + store.users.find((u) => u.externalAuthId === "ext-new"), + ).toBeTruthy(); + }); + + it("reuses an existing row by externalAuthId", async () => { + const user = await resolveInviteeUser("ext-dev", "dev@example.com"); + expect(user).toEqual({ id: "user-dev", email: "dev@example.com" }); + expect(store.users).toHaveLength(1); + }); + + it("REFUSES an email owned by a different auth identity (never relinks)", async () => { + await expect( + resolveInviteeUser("ext-imposter", "dev@example.com"), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: IDENTITY_CONFLICT_ERROR, + }); + // No row was created for the conflicting identity. + expect( + store.users.find((u) => u.externalAuthId === "ext-imposter"), + ).toBeUndefined(); + }); +}); + +describe("describeInvitation", () => { + const viewer = { externalAuthId: "ext-dev", email: "dev@example.com" }; + + it("answers not-found for an unknown token, with NOTHING but the state", async () => { + const view = await describeInvitation("inv_who-knows", null); + expect(view).toEqual({ state: "not-found" }); + }); + + it("answers signin-required with the invite summary for a signed-out visitor", async () => { + const view = await describeInvitation(TOKEN, null); + expect(view).toMatchObject({ + state: "signin-required", + organizationName: "Acme", + invitedEmail: "dev@example.com", + role: "admin", + invitedByEmail: "admin@example.com", + }); + }); + + it("answers ready for the invited visitor", async () => { + const view = await describeInvitation(TOKEN, viewer); + expect(view).toMatchObject({ state: "ready", organizationName: "Acme" }); + }); + + it("answers ready for a signed-in visitor with NO user row yet", async () => { + const view = await describeInvitation(TOKEN, { + externalAuthId: "ext-brand-new", + email: "dev@example.com", + }); + expect(view).toMatchObject({ state: "ready" }); + }); + + it("answers wrong-email naming both addresses", async () => { + const view = await describeInvitation(TOKEN, { + externalAuthId: "ext-other", + email: "other@example.com", + }); + expect(view).toEqual({ + state: "wrong-email", + organizationName: "Acme", + invitedEmail: "dev@example.com", + viewerEmail: "other@example.com", + }); + }); + + it("answers revoked / accepted for those stored statuses", async () => { + store.invitations[0]!.status = "cancelled"; + expect(await describeInvitation(TOKEN, null)).toEqual({ + state: "revoked", + organizationName: "Acme", + }); + store.invitations[0]!.status = "accepted"; + expect(await describeInvitation(TOKEN, viewer)).toEqual({ + state: "accepted", + organizationName: "Acme", + }); + }); + + it("projects a past-expiry pending row as expired WITHOUT writing", async () => { + store.invitations[0]!.expiresAt = new Date(Date.now() - 1000); + expect(await describeInvitation(TOKEN, viewer)).toEqual({ + state: "expired", + organizationName: "Acme", + }); + // Read-only: the lazy write-back belongs to the accept path alone. + expect(store.invitations[0]?.status).toBe("pending"); + }); + + it("answers already-member / suspended from the viewer's membership, outranking status", async () => { + store.members.push({ + organizationId: ORG, + userId: "user-dev", + userEmail: "dev@example.com", + role: "member", + status: "active", + }); + store.invitations[0]!.status = "accepted"; + expect(await describeInvitation(TOKEN, viewer)).toEqual({ + state: "already-member", + organizationName: "Acme", + }); + + store.members[0]!.status = "suspended"; + expect(await describeInvitation(TOKEN, viewer)).toEqual({ + state: "suspended", + organizationName: "Acme", + }); + }); + + it("answers other-org for a user who already has their own workspace (D-A)", async () => { + store.members.push({ + organizationId: OTHER_ORG, + userId: "user-dev", + userEmail: "dev@example.com", + role: "owner", + status: "active", + }); + const view = await describeInvitation(TOKEN, viewer); + expect(view).toMatchObject({ + state: "other-org", + organizationName: "Acme", + invitedEmail: "dev@example.com", + }); + }); +}); + +describe("generateInvitationToken", () => { + it("emits inv_ + 64 hex chars, unique across 1000 draws", () => { + const tokens = new Set(); + for (let i = 0; i < 1000; i++) { + const token = generateInvitationToken(); + expect(token).toMatch(/^inv_[0-9a-f]{64}$/); + tokens.add(token); + } + expect(tokens.size).toBe(1000); + }); +}); diff --git a/packages/api/src/services/org-invitation-service.ts b/packages/api/src/services/org-invitation-service.ts new file mode 100644 index 00000000..b915f0a9 --- /dev/null +++ b/packages/api/src/services/org-invitation-service.ts @@ -0,0 +1,595 @@ +import { randomBytes } from "crypto"; +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { + clampDirectoryLimit, + decodeCursor, + toDirectoryPage, + type DirectoryPage, +} from "../lib/cursor"; +import { ensureMemberDefaultProject } from "./organization-service"; +import { IDENTITY_CONFLICT_ERROR } from "../routes/auth-session"; +import type { CreateInvitationInput } from "../validations/org"; +import type { z } from "zod"; +import type { invitationStatusSchema } from "../validations/org"; + +// Link-based org invitations: the admin write side (create / revoke / list) +// plus the invitee-facing reads and the accept path. Scoped to ONE +// organization on every admin call — the caller's `auth.organizationId`, +// never a body/query parameter — so this can never read or write across +// orgs. The token-keyed entry points (`describeInvitation`, +// `acceptInvitation`) derive their org from the token's own row. +// +// The token is a bearer secret: it must NEVER appear in audit metadata, log +// lines, or error messages. The only channels that carry it are the create +// response and the admin list (both admin-only, D-D). + +export type InvitationStatus = z.infer; + +const INVITATION_TTL_DAYS = 7; +const MAX_PENDING_INVITATIONS = 100; + +/** House-style secret token (matches `generateApiKey`): prefix + 32 random bytes. */ +export const generateInvitationToken = () => + `inv_${randomBytes(32).toString("hex")}`; + +const invitationExpiry = () => + new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000); + +/** One row of the invitations directory (matches the client's `InvitationRow`). */ +export interface InvitationListRow { + id: string; + email: string; + role: string; + /** PROJECTED: a stored "pending" past its expiresAt reads "expired". */ + status: string; + invitedByEmail: string; + expiresAt: string; + createdAt: string; + /** Raw link token — admin-only channel (the UI composes /join/). */ + token: string; +} + +export interface ListOrgInvitationsParams { + limit?: number; + cursor?: string; + q?: string; + status?: InvitationStatus; +} + +/** + * Same keyset shape as the members directory: ordered `createdAt asc, id asc` + * and paged by that exact two-part key, since `createdAt` alone is not unique. + */ +const CURSOR_PARTS = 2; + +const cursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, id] = parts; + if (createdAtIso === undefined || id === undefined) return undefined; + const createdAt = new Date(createdAtIso); + // A cursor whose timestamp half is not a date is malformed — serve page one + // rather than handing an Invalid Date to the query layer. + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [{ createdAt: { gt: createdAt } }, { createdAt, id: { gt: id } }], + }; +}; + +const isExpired = (expiresAt: Date, now = new Date()) => + expiresAt.getTime() <= now.getTime(); + +/** + * Display-only expiry projection: a stored "pending" past its `expiresAt` + * reads "expired" WITHOUT a write (there is no sweeper in OSS; the lazy + * write-back happens only on an accept attempt). + */ +const projectStatus = (status: string, expiresAt: Date, now: Date) => + status === "pending" && isExpired(expiresAt, now) ? "expired" : status; + +export const listOrgInvitations = async ( + organizationId: string, + params: ListOrgInvitationsParams = {}, +): Promise> => { + const limit = clampDirectoryLimit(params.limit); + const after = cursorFilter(params.cursor); + const q = params.q?.trim(); + const now = new Date(); + + const rows = await db.invitation.findMany({ + where: { + organizationId, + // The filter applies to the STORED status: the expiry projection below + // is display-only, so `status=pending` can return rows that render as + // "expired". Deliberate — "fixing" the mismatch here would make the + // filter disagree with the database and with the accept path. + ...(params.status ? { status: params.status } : {}), + ...(q ? { email: { contains: q, mode: "insensitive" as const } } : {}), + // The keyset predicate lives under AND, never as a top-level `OR` spread: + // a future filter that also needs `OR` would otherwise overwrite the + // cursor clause and silently restart pagination from the first page. + ...(after ? { AND: [after] } : {}), + }, + select: { + id: true, + email: true, + role: true, + status: true, + invitedByEmail: true, + expiresAt: true, + createdAt: true, + token: true, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: limit + 1, + }); + + const invitations: InvitationListRow[] = rows.map((row) => ({ + id: row.id, + email: row.email, + role: row.role, + status: projectStatus(row.status, row.expiresAt, now), + invitedByEmail: row.invitedByEmail, + expiresAt: row.expiresAt.toISOString(), + createdAt: row.createdAt.toISOString(), + token: row.token, + })); + + return toDirectoryPage(invitations, limit, (row) => [row.createdAt, row.id]); +}; + +/** + * Create (or re-issue) an invitation for an email address. + * + * Ordered guards: + * 1. self-invite (400); + * 2. the address already belongs to a member of this org — active (409) and + * suspended (409, distinct message: the fix is reinstating, not inviting); + * 3. a pending, unexpired invitation already exists (409 — copy its link or + * revoke it first); any other stored status, or an expired pending row, + * falls through and is OVERWRITTEN; + * 4. the pending cap (409). + * + * The write is an upsert because `@@unique([organizationId, email])` means a + * plain create would 500 on any re-invite; the update arm re-mints the token + * and resets the row to a fresh pending state. + */ +export const createOrgInvitation = async ( + organizationId: string, + invitedById: string, + invitedByEmail: string, + input: CreateInvitationInput, +): Promise => { + const email = input.email; // already trimmed + lowercased by the schema + const now = new Date(); + + if (email === invitedByEmail.trim().toLowerCase()) { + throw new ServiceError( + "BAD_REQUEST", + "You are already in this organization.", + ); + } + + const existingUser = await db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (existingUser) { + const membership = await db.organizationMember.findUnique({ + where: { + organizationId_userId: { organizationId, userId: existingUser.id }, + }, + select: { status: true }, + }); + if (membership) { + if (membership.status === "suspended") { + throw new ServiceError( + "CONFLICT", + "That address belongs to a suspended member — reinstate them from the members list instead.", + ); + } + throw new ServiceError( + "CONFLICT", + "That address is already a member of this organization.", + ); + } + } + + const existing = await db.invitation.findUnique({ + where: { organizationId_email: { organizationId, email } }, + select: { status: true, expiresAt: true }, + }); + if ( + existing && + existing.status === "pending" && + !isExpired(existing.expiresAt, now) + ) { + throw new ServiceError( + "CONFLICT", + "An invitation is already pending for this address — copy its link or revoke it first.", + ); + } + + const pendingCount = await db.invitation.count({ + where: { organizationId, status: "pending" }, + }); + if (pendingCount >= MAX_PENDING_INVITATIONS) { + throw new ServiceError( + "CONFLICT", + `This organization already has ${MAX_PENDING_INVITATIONS} pending invitations — revoke unused ones first.`, + ); + } + + const fresh = { + token: generateInvitationToken(), + status: "pending", + role: input.role, + invitedById, + invitedByEmail, + expiresAt: invitationExpiry(), + }; + + const row = await db.invitation.upsert({ + where: { organizationId_email: { organizationId, email } }, + create: { organizationId, email, ...fresh }, + update: fresh, + }); + + return { + id: row.id, + email: row.email, + role: row.role, + status: row.status, + invitedByEmail: row.invitedByEmail, + expiresAt: row.expiresAt.toISOString(), + createdAt: row.createdAt.toISOString(), + token: row.token, + }; +}; + +/** + * Revoke a pending invitation: status → "cancelled" (the schema's value), + * never a hard delete — the row keeps its audit trail and its unique slot. + * + * The conditional update is scoped `{ id, organizationId, status: "pending" }`; + * on a miss the re-read is scoped `{ id, organizationId }` too (NEVER a bare + * `findUnique({ where: { id } })`) so a cross-org id reads as absent (404) + * rather than leaking another org's row into the 409 arm. + */ +export const revokeOrgInvitation = async ( + organizationId: string, + invitationId: string, +): Promise<{ id: string; email: string }> => { + const { count } = await db.invitation.updateMany({ + where: { id: invitationId, organizationId, status: "pending" }, + data: { status: "cancelled" }, + }); + + const row = await db.invitation.findFirst({ + where: { id: invitationId, organizationId }, + select: { id: true, email: true }, + }); + + if (!row) throw new ServiceError("NOT_FOUND", "Invitation not found."); + if (count === 0) { + throw new ServiceError( + "CONFLICT", + "Only pending invitations can be revoked.", + ); + } + return row; +}; + +/** The join page's viewer: the auth session, which may have no DB user row yet. */ +export interface InvitationViewer { + /** The EXTERNAL auth id (`session.id`), never a DB `User.id`. */ + externalAuthId: string; + email: string; +} + +/** + * Discriminated union for the join page — a pure switch over `state`, so the + * page never branches on strings or recomputes policy (D-L). `not-found` + * carries NOTHING but the state: an unknown token must stay generic. + */ +export type InvitationView = + | { state: "not-found" } + | { state: "revoked" | "expired" | "accepted"; organizationName: string } + | { state: "already-member"; organizationName: string } + | { state: "suspended"; organizationName: string } + | { + state: "signin-required" | "ready" | "other-org"; + organizationName: string; + invitedEmail: string; + role: string; + invitedByEmail: string; + expiresAt: string; + } + | { + state: "wrong-email"; + organizationName: string; + invitedEmail: string; + viewerEmail: string; + }; + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +/** + * Read-only description of an invitation for its landing page. Never writes: + * the expiry projection is display-only, and the viewer's DB user row is only + * LOOKED UP (a GET render must never create state). + */ +export const describeInvitation = async ( + token: string, + viewer: InvitationViewer | null, +): Promise => { + const invitation = await db.invitation.findUnique({ + where: { token }, + include: { organization: { select: { name: true } } }, + }); + if (!invitation) return { state: "not-found" }; + + const organizationName = invitation.organization.name; + + // A signed-in viewer who is already on the org's roster gets the + // membership answer FIRST — it outranks every invitation-status state + // (their own accepted invitation should read "you're already in"). + if (viewer) { + const dbUser = await db.user.findUnique({ + where: { externalAuthId: viewer.externalAuthId }, + select: { id: true }, + }); + if (dbUser) { + const membership = await db.organizationMember.findUnique({ + where: { + organizationId_userId: { + organizationId: invitation.organizationId, + userId: dbUser.id, + }, + }, + select: { status: true }, + }); + if (membership) { + return membership.status === "suspended" + ? { state: "suspended", organizationName } + : { state: "already-member", organizationName }; + } + } + + if (invitation.status === "cancelled") + return { state: "revoked", organizationName }; + if (invitation.status === "accepted") + return { state: "accepted", organizationName }; + if (invitation.status === "expired" || isExpired(invitation.expiresAt)) + return { state: "expired", organizationName }; + + if (normalizeEmail(viewer.email) !== invitation.email) { + return { + state: "wrong-email", + organizationName, + invitedEmail: invitation.email, + viewerEmail: viewer.email, + }; + } + + const summary = { + organizationName, + invitedEmail: invitation.email, + role: invitation.role, + invitedByEmail: invitation.invitedByEmail, + expiresAt: invitation.expiresAt.toISOString(), + }; + + // D-A: an already-bootstrapped user keeps landing in their own org after + // joining (`findUserDefaultProject` arm 1) — accept still works, but the + // page warns before the Join click. + if (dbUser) { + const otherMembership = await db.organizationMember.findFirst({ + where: { + userId: dbUser.id, + organizationId: { not: invitation.organizationId }, + }, + select: { organizationId: true }, + }); + if (otherMembership) return { state: "other-org", ...summary }; + } + + return { state: "ready", ...summary }; + } + + if (invitation.status === "cancelled") + return { state: "revoked", organizationName }; + if (invitation.status === "accepted") + return { state: "accepted", organizationName }; + if (invitation.status === "expired" || isExpired(invitation.expiresAt)) + return { state: "expired", organizationName }; + + return { + state: "signin-required", + organizationName, + invitedEmail: invitation.email, + role: invitation.role, + invitedByEmail: invitation.invitedByEmail, + expiresAt: invitation.expiresAt.toISOString(), + }; +}; + +export interface AcceptResult { + organizationId: string; + organizationName: string; + projectId: string; + role: string; + alreadyMember: boolean; + invitationId: string; +} + +/** + * Accept an invitation by token. Ordered invariants: + * 1. unknown token → 404 (generic — no oracle for token guessing); + * 2. non-pending → 410; + * 3. past expiry → best-effort write-back to "expired", then 410; + * 4. session email must match the invited address (D-C) → 403. A failed + * attempt must NOT burn the token, which is why this check precedes the + * single-use claim; + * 5. already a member: active → idempotent success (mark accepted, + * find-or-create the default project); suspended → 409 (an invite link + * must never bypass a suspension — reinstate from /team); + * 6. SINGLE-USE CLAIM: a conditional pending→accepted update that must win + * (`count === 1`) BEFORE the membership is created. Deliberately not a + * $transaction (zero usage in this package); the known trade-off is that a + * post-claim failure burns the invitation and the admin re-invites. + * 7. membership upsert (idempotent against a concurrent join); + * 8. `ensureMemberDefaultProject` is awaited and NOT best-effort: without a + * project the session redirect dead-ends on /create-org, which does not + * exist in OSS. + */ +export const acceptInvitation = async ( + token: string, + userId: string, + userEmail: string, +): Promise => { + const invitation = await db.invitation.findUnique({ + where: { token }, + include: { organization: { select: { name: true } } }, + }); + if (!invitation) { + throw new ServiceError("NOT_FOUND", "Invitation not found."); + } + + const { organizationId } = invitation; + const organizationName = invitation.organization.name; + + if (invitation.status !== "pending") { + throw new ServiceError("GONE", "This invitation is no longer active."); + } + + if (isExpired(invitation.expiresAt)) { + // Lazy write-back: best-effort, conditional so a concurrent accept/revoke + // is never overwritten. + await db.invitation.updateMany({ + where: { id: invitation.id, status: "pending" }, + data: { status: "expired" }, + }); + throw new ServiceError("GONE", "This invitation has expired."); + } + + if (normalizeEmail(userEmail) !== invitation.email) { + throw new ServiceError( + "FORBIDDEN", + `This invitation was issued to ${invitation.email}, but you are signed in as ${userEmail}. Sign in with the invited address to join.`, + ); + } + + const existingMembership = await db.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + select: { role: true, status: true }, + }); + + if (existingMembership) { + if (existingMembership.status === "suspended") { + throw new ServiceError( + "CONFLICT", + "Your membership in this organization is suspended. Ask an admin to reinstate you — an invitation cannot bypass a suspension.", + ); + } + // Idempotent success: mark the invitation used (conditionally — a lost + // race just means someone else already marked it) and make sure the + // member has a project to land on. + await db.invitation.updateMany({ + where: { id: invitation.id, status: "pending" }, + data: { status: "accepted" }, + }); + const project = await ensureMemberDefaultProject( + organizationId, + userId, + userEmail, + ); + return { + organizationId, + organizationName, + projectId: project.id, + role: existingMembership.role, + alreadyMember: true, + invitationId: invitation.id, + }; + } + + const { count } = await db.invitation.updateMany({ + where: { id: invitation.id, status: "pending" }, + data: { status: "accepted" }, + }); + if (count !== 1) { + throw new ServiceError("GONE", "This invitation is no longer active."); + } + + await db.organizationMember.upsert({ + where: { organizationId_userId: { organizationId, userId } }, + create: { + organizationId, + userId, + userEmail, + role: invitation.role, + }, + update: {}, + }); + + const project = await ensureMemberDefaultProject( + organizationId, + userId, + userEmail, + ); + + return { + organizationId, + organizationName, + projectId: project.id, + role: invitation.role, + alreadyMember: false, + invitationId: invitation.id, + }; +}; + +/** + * Resolve (or create) the DB user row for an authenticated invitee — the + * accept path's replacement for the `/v1/auth/session` upsert, which a + * brand-new invitee must NOT hit before accepting (it would bootstrap them + * their own org). + * + * D-H: an email that already belongs to a DIFFERENT auth identity is REFUSED + * (409, the same message the session route uses) — this path never silently + * relinks an identity. + */ +export const resolveInviteeUser = async ( + externalAuthId: string, + email: string, + name?: string, +): Promise<{ id: string; email: string }> => { + const byAuthId = await db.user.findUnique({ + where: { externalAuthId }, + select: { id: true, email: true }, + }); + if (byAuthId) return byAuthId; + + const byEmail = await db.user.findUnique({ + where: { email }, + select: { id: true, email: true, externalAuthId: true }, + }); + if (byEmail) { + if (byEmail.externalAuthId === externalAuthId) { + return { id: byEmail.id, email: byEmail.email }; + } + throw new ServiceError("CONFLICT", IDENTITY_CONFLICT_ERROR); + } + + const created = await db.user.create({ + data: { + externalAuthId, + email, + name: name ?? null, + lastLoginAt: new Date(), + }, + select: { id: true, email: true }, + }); + return created; +}; diff --git a/packages/api/src/services/org-member-service.ts b/packages/api/src/services/org-member-service.ts new file mode 100644 index 00000000..7991c198 --- /dev/null +++ b/packages/api/src/services/org-member-service.ts @@ -0,0 +1,221 @@ +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { + clampDirectoryLimit, + decodeCursor, + toDirectoryPage, + type DirectoryPage, +} from "../lib/cursor"; +import type { + orgMemberRoleSchema, + orgMemberStatusSchema, +} from "../validations/org"; +import type { z } from "zod"; + +// The org's membership directory: the read side of the Team surface plus the +// two writes an admin can make (lifecycle + role). Scoped to ONE organization +// on every call — the caller's `auth.organizationId`, never a body/query +// parameter — so this can never read or write across orgs. + +export type OrgMemberStatus = z.infer; +export type AssignableOrgRole = z.infer; + +/** One row of the members directory (matches the client's `OrgMemberListRow`). */ +export interface OrgMemberListRow { + userId: string; + email: string; + name: string | null; + role: string; + status: string; + /** EE-only break-glass SSO exemption — always false in OSS. */ + ssoExempt: boolean; + joinedAt: string; +} + +export interface ListOrgMembersParams { + limit?: number; + cursor?: string; + q?: string; + status?: OrgMemberStatus; +} + +/** + * The list is ordered `createdAt asc, userId asc` and paged by that exact key: + * `OrganizationMember` has a COMPOSITE primary key (no `id` column), and + * `createdAt` alone is not unique, so the cursor must carry both halves or a + * concurrent join could make a page skip or repeat rows. + */ +const CURSOR_PARTS = 2; + +const cursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, userId] = parts; + if (createdAtIso === undefined || userId === undefined) return undefined; + const createdAt = new Date(createdAtIso); + // A cursor whose timestamp half is not a date is malformed — serve page one + // rather than handing an Invalid Date to the query layer. + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [ + { createdAt: { gt: createdAt } }, + { createdAt, userId: { gt: userId } }, + ], + }; +}; + +export const listOrgMembers = async ( + organizationId: string, + params: ListOrgMembersParams = {}, +): Promise> => { + const limit = clampDirectoryLimit(params.limit); + const after = cursorFilter(params.cursor); + const q = params.q?.trim(); + + const rows = await db.organizationMember.findMany({ + where: { + organizationId, + ...(params.status ? { status: params.status } : {}), + ...(q + ? { + user: { + OR: [ + { email: { contains: q, mode: "insensitive" as const } }, + { name: { contains: q, mode: "insensitive" as const } }, + ], + }, + } + : {}), + // The keyset predicate lives under AND, never as a top-level `OR` spread: + // a future filter that also needs `OR` would otherwise overwrite the + // cursor clause and silently restart pagination from the first page. + ...(after ? { AND: [after] } : {}), + }, + select: { + userId: true, + role: true, + status: true, + ssoExempt: true, + createdAt: true, + user: { select: { email: true, name: true } }, + }, + orderBy: [{ createdAt: "asc" }, { userId: "asc" }], + take: limit + 1, + }); + + const members: OrgMemberListRow[] = rows.map((row) => ({ + userId: row.userId, + email: row.user.email, + name: row.user.name, + role: row.role, + status: row.status, + ssoExempt: row.ssoExempt, + joinedAt: row.createdAt.toISOString(), + })); + + return toDirectoryPage(members, limit, (row) => [row.joinedAt, row.userId]); +}; + +const requireMember = async (organizationId: string, userId: string) => { + const member = await db.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + select: { role: true, status: true }, + }); + if (!member) { + throw new ServiceError( + "NOT_FOUND", + "That user is not a member of this organization.", + ); + } + return member; +}; + +const countActiveOwners = (organizationId: string) => + db.organizationMember.count({ + where: { organizationId, role: "owner", status: { not: "suspended" } }, + }); + +/** + * Suspend or reinstate a member. + * + * Invariants, in order: + * - the target must be a member of the acting org (404); + * - nobody may change their OWN status (400) — an admin suspending themselves + * would lock themselves out of the surface that could undo it; + * - owners are never SUSPENDABLE (403), and suspending the org's ONLY active + * owner reports the sharper conflict (409): an instance with no active owner + * has no recovery path. + * + * The owner guard covers the suspend direction ONLY. REINSTATING an owner must + * stay possible: an org whose sole owner is somehow suspended (a hand-edited + * row, an import, a future code path) is exactly the unrecoverable state these + * rules exist to prevent, and refusing the repair would cement it. + */ +export const updateOrgMemberStatus = async ( + organizationId: string, + actorUserId: string, + targetUserId: string, + status: OrgMemberStatus, +): Promise<{ userId: string; status: string; ssoExempt: boolean }> => { + const member = await requireMember(organizationId, targetUserId); + + if (targetUserId === actorUserId) { + throw new ServiceError("BAD_REQUEST", "You cannot suspend yourself."); + } + + if (member.role === "owner" && status === "suspended") { + if ((await countActiveOwners(organizationId)) <= 1) + throw new ServiceError( + "CONFLICT", + "The organization must keep at least one active owner.", + ); + throw new ServiceError( + "FORBIDDEN", + "Organization owners cannot be suspended.", + ); + } + + return db.organizationMember.update({ + where: { organizationId_userId: { organizationId, userId: targetUserId } }, + data: { + status, + suspendedAt: status === "suspended" ? new Date() : null, + }, + select: { userId: true, status: true, ssoExempt: true }, + }); +}; + +/** + * Change a member's org role. + * + * Only `admin` and `member` are assignable (the schema enforces it): owner + * transfer is a separate, deliberately out-of-scope operation. Invariants: + * target must be a member (404); nobody may change their own role (400 — an + * admin cannot demote themselves out of the surface); owners are untouchable + * here (403). + */ +export const updateOrgMemberRole = async ( + organizationId: string, + actorUserId: string, + targetUserId: string, + role: AssignableOrgRole, +): Promise<{ userId: string; role: string }> => { + const member = await requireMember(organizationId, targetUserId); + + if (targetUserId === actorUserId) { + throw new ServiceError("BAD_REQUEST", "You cannot change your own role."); + } + + if (member.role === "owner") { + throw new ServiceError( + "FORBIDDEN", + "The organization owner's role cannot be changed.", + ); + } + + return db.organizationMember.update({ + where: { organizationId_userId: { organizationId, userId: targetUserId } }, + data: { role }, + select: { userId: true, role: true }, + }); +}; diff --git a/packages/api/src/services/org-role-resolver.test.ts b/packages/api/src/services/org-role-resolver.test.ts new file mode 100644 index 00000000..1ae9f06a --- /dev/null +++ b/packages/api/src/services/org-role-resolver.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The OSS role resolver is what makes `CAPS.rbac` safe to turn on: every access +// check funnels through it, so a wrong answer here is either a lockout or a +// privilege escalation. Pin the oss edition — this provider is OSS-only. + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; +}); + +const state = vi.hoisted(() => ({ + member: null as { role: string; status: string } | null, + lastWhere: null as unknown, +})); + +vi.mock("@onecli/db", () => ({ + Prisma: {}, + db: { + organizationMember: { + findUnique: async ({ where }: { where: unknown }) => { + state.lastWhere = where; + return state.member; + }, + }, + }, +})); + +const warn = vi.hoisted(() => vi.fn()); + +vi.mock("../lib/logger", () => ({ + logger: { child: () => ({ warn }) }, +})); + +import { ossRoleResolver } from "./org-role-resolver"; + +const ORG = "org-1"; +const USER = "user-1"; + +beforeEach(() => { + state.member = null; + state.lastWhere = null; + warn.mockClear(); +}); + +describe("ossRoleResolver", () => { + it("reads the membership by its composite primary key", async () => { + state.member = { role: "owner", status: "active" }; + await ossRoleResolver.getUserRole(USER, ORG); + expect(state.lastWhere).toEqual({ + organizationId_userId: { organizationId: ORG, userId: USER }, + }); + }); + + it.each([ + ["owner", "owner"], + ["admin", "admin"], + ["member", "member"], + ])("maps an active %s to %p", async (role, expected) => { + state.member = { role, status: "active" }; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBe( + expected, + ); + }); + + it("returns null for a non-member", async () => { + state.member = null; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBeNull(); + }); + + it.each(["owner", "admin", "member"])( + "returns null for a suspended %s (suspended = non-member)", + async (role) => { + state.member = { role, status: "suspended" }; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBeNull(); + }, + ); + + it.each(["", "OWNER", "superadmin", "Admin", "member "])( + "returns null (and warns) for the unrecognized role %p", + async (role) => { + state.member = { role, status: "active" }; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }, + ); + + it("does not treat inherited Object properties as roles", async () => { + // A naive `ROLE_HIERARCHY[role]` lookup would resolve "constructor" or + // "toString" to a truthy value and let a junk row through. + state.member = { role: "constructor", status: "active" }; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBeNull(); + }); + + it("keeps an unknown status other than 'suspended' usable", async () => { + // Only "suspended" is a deny signal; anything else (e.g. the default + // "active", or a future value) is an ordinary active membership. + state.member = { role: "admin", status: "active" }; + await expect(ossRoleResolver.getUserRole(USER, ORG)).resolves.toBe("admin"); + }); +}); diff --git a/packages/api/src/services/org-role-resolver.ts b/packages/api/src/services/org-role-resolver.ts new file mode 100644 index 00000000..91dd0eca --- /dev/null +++ b/packages/api/src/services/org-role-resolver.ts @@ -0,0 +1,55 @@ +import { db } from "@onecli/db"; +import type { OrgRole, RoleResolver } from "../providers"; +import { ROLE_HIERARCHY } from "../providers"; +import { logger } from "../lib/logger"; + +/** + * The OSS edition's `RoleResolver`: the org-membership row IS the role source + * of truth (no directory, no SSO mappings, no role automation). + * + * Wired through `CreateApiAppOptions.roleResolver` from the OSS init seam. It + * is a HARD prerequisite for `CAPS.rbac`: with rbac on and no resolver every + * access check reads "no role" and denies — `canAccessProjectAsUser` + * (`middleware/auth/resolve.ts`), the org-key admin re-check + * (`middleware/auth/api-key.ts`) and `auth({ role })` all fail closed. The + * resolver and the capability flag must always ship together. + */ + +const isOrgRole = (role: string): role is OrgRole => + Object.prototype.hasOwnProperty.call(ROLE_HIERARCHY, role); + +const log = logger.child({ component: "oss-role-resolver" }); + +export const ossRoleResolver: RoleResolver = { + getUserRole: async ( + userId: string, + organizationId: string, + ): Promise => { + const member = await db.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + select: { role: true, status: true }, + }); + + // Not a member of this org. + if (!member) return null; + + // Suspended members are non-members to every authorization check — the + // same invariant `activeMembershipWhere` applies on the read side. Keeping + // it here is what stops a stale ProjectAccess binding from rescuing a + // suspended user (the binding check lives inside the active-member gate). + if (member.status === "suspended") return null; + + // `role` is a free-form column. Never cast a raw DB string into `OrgRole`: + // an unrecognized value (bad migration, hand-edited row, a role a newer + // edition writes) resolves to "no role" and fails closed. + if (!isOrgRole(member.role)) { + log.warn( + { userId, organizationId, role: member.role }, + "unrecognized organization member role — treating as no role", + ); + return null; + } + + return member.role; + }, +}; diff --git a/packages/api/src/services/organization-service.ts b/packages/api/src/services/organization-service.ts index a637ec40..ce43d0df 100644 --- a/packages/api/src/services/organization-service.ts +++ b/packages/api/src/services/organization-service.ts @@ -14,11 +14,11 @@ export const slugify = (raw: string) => /** * Membership filter every ACCESS-GRANTING read applies: suspended members are - * treated as non-members by all authorization checks (the write-side lives in - * the EE team service; nothing sets "suspended" in OSS, so this is inert - * there). Deliberately NOT applied to display lists, seat counts, or the - * provisioning/JIT existence guards — filtering those would re-mint - * memberships for suspended users. + * treated as non-members by all authorization checks. Live in OSS as well as + * cloud — `PATCH /v1/org/members/:userId` is the OSS write-side, so a suspended + * row is a state this edition really reaches. Deliberately NOT applied to + * display lists, seat counts, or the provisioning/JIT existence guards — + * filtering those would re-mint memberships for suspended users. */ export const activeMembershipWhere = { status: { not: "suspended" }, @@ -30,10 +30,30 @@ export const activeMembershipWhere = { * * Used by `resolveUser()`, `resolveApiAuth()`, and the session route to map * an authenticated user to a project without creating anything. + * + * Two arms, in order — both scoped to orgs the user is an ACTIVE member of: + * 1. the oldest project the user CREATED. Searched across every active + * membership, not just the first one: a user who was invited to someone + * else's org and later bootstrapped their own would otherwise resolve to + * the older foreign org, and their OWN project would be shadowed by a + * project they were merely shared into. + * 2. otherwise the oldest project they hold a `ProjectAccess` binding on + * (directly or through a group). Without this an INVITED member — who + * creates nothing — resolves to no project at all: session auth then + * demands an `X-Organization-Id` header that OSS never sends (401 + * everywhere) and `resolveProjectContext` throws. The binding is the same + * gate `canAccessProjectAsUser` applies, so this arm can only ever return + * a project the user may actually use. + * + * Both arms tiebreak on `id` so a `createdAt` collision (two projects seeded in + * the same millisecond) resolves deterministically instead of leaving the + * caller's project up to the query planner. */ export const findUserDefaultProject = async ( userId: string, ): Promise<{ id: string; organizationId: string } | null> => { + // Cheap early-out for the pre-bootstrap user, and the reason both arms below + // can assume at least one active membership exists. const membership = await db.organizationMember.findFirst({ where: { userId, ...activeMembershipWhere }, select: { organizationId: true }, @@ -41,13 +61,28 @@ export const findUserDefaultProject = async ( }); if (!membership) return null; + const inActiveMemberOrg = { + organization: { members: { some: { userId, ...activeMembershipWhere } } }, + }; + + const created = await db.project.findFirst({ + where: { ...inActiveMemberOrg, createdByUserId: userId }, + select: { id: true, organizationId: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + if (created) return created; + return db.project.findFirst({ where: { - organizationId: membership.organizationId, - createdByUserId: userId, + ...inActiveMemberOrg, + accessBindings: { + some: { + OR: [{ userId }, { group: { members: { some: { userId } } } }], + }, + }, }, select: { id: true, organizationId: true }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); }; @@ -114,8 +149,8 @@ export const bootstrapOrganization = async ( createdByUserEmail: userEmail, ...defaultProjectSeed(userId, userEmail), // Creator's ProjectAccess binding (step 13), seeded owner (13c) with the - // project. Inert in OSS (nothing reads bindings without RBAC); load-bearing - // in cloud. + // project. Load-bearing in every rbac edition, OSS included: without it a + // non-admin member is denied their own project by `canAccessProjectAsUser`. accessBindings: { create: { userId, role: "owner" } }, }, select: { id: true, organizationId: true }, @@ -237,7 +272,8 @@ export const joinSharedOrganization = async ( organizationId: org.id, createdByUserId: userId, createdByUserEmail: userEmail, - // Creator's ProjectAccess binding (step 13), seeded owner (13c). Inert in OSS. + // Creator's ProjectAccess binding (step 13), seeded owner (13c) — the + // member's own usage gate wherever rbac is on. accessBindings: { create: { userId, role: "owner" } }, }, select: { id: true, organizationId: true }, @@ -258,6 +294,61 @@ export const joinSharedOrganization = async ( return { project, organization: org }; }; +/** + * Give a member their own default project inside an EXISTING organization — + * the invited-member counterpart to `bootstrapOrganization` (which creates the + * org too). Find-or-create and therefore idempotent. + * + * The `accessBindings` row is load-bearing, not decoration: with `CAPS.rbac` + * on, a plain member reaches a project only as an org admin/owner or through a + * ProjectAccess binding (`canAccessProjectAsUser`). Creating the project + * without the binding would hand the member a project they are then denied + * access to on every request. + * + * The project slug is unique per org (`@@unique([organizationId, slug])`), and + * an existing org already owns the plain `default` slug, so the member's slug + * carries their user id. + */ +export const ensureMemberDefaultProject = async ( + organizationId: string, + userId: string, + userEmail: string, +) => { + const existing = await db.project.findFirst({ + where: { organizationId, createdByUserId: userId }, + select: { id: true, organizationId: true }, + orderBy: { createdAt: "asc" }, + }); + if (existing) return existing; + + const project = await db.project.create({ + data: { + id: generateProjectId(), + name: "Default", + slug: `default-${userId}`, + organizationId, + createdByUserId: userId, + createdByUserEmail: userEmail, + ...defaultProjectSeed(userId, userEmail), + accessBindings: { create: { userId, role: "owner" } }, + }, + select: { id: true, organizationId: true }, + }); + + // Best-effort, exactly as the other provision sites: a seeding hiccup must + // not fail the member's first login. + try { + await getNewOrgPolicySeeder().seed(organizationId, project.id); + } catch (err) { + logger.warn( + { err, organizationId, projectId: project.id }, + "member project policy seed failed", + ); + } + + return project; +}; + export const validateOrgName = (raw: string): string => { const trimmed = raw.trim(); if (!trimmed || trimmed.length > 255) { diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts new file mode 100644 index 00000000..1e42d62e --- /dev/null +++ b/packages/api/src/validations/org.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { + DIRECTORY_LIMIT_DEFAULT, + DIRECTORY_LIMIT_MAX, + DIRECTORY_LIMIT_MIN, +} from "../lib/cursor"; + +// Validation for the `/v1/org/*` directory surface. Query strings arrive as +// raw strings (`c.req.query()`), so `limit` is coerced; everything else is +// optional and bounded. + +/** The list-query contract every directory-scale list shares. */ +export const directoryListQuerySchema = z.object({ + limit: z.coerce + .number() + .int() + .min(DIRECTORY_LIMIT_MIN) + .max(DIRECTORY_LIMIT_MAX) + .default(DIRECTORY_LIMIT_DEFAULT), + /** Opaque page cursor — echoed back from a previous page's `nextCursor`. */ + cursor: z.string().optional(), + /** Free-text filter (case-insensitive substring over the row's identity). */ + q: z.string().max(200).optional(), +}); + +export type DirectoryListQuery = z.infer; + +export const orgMemberStatusSchema = z.enum(["active", "suspended"]); + +/** Assignable member roles: `owner` is not assignable through this surface. */ +export const orgMemberRoleSchema = z.enum(["admin", "member"]); + +export const orgMemberListQuerySchema = directoryListQuerySchema.extend({ + status: orgMemberStatusSchema.optional(), +}); + +export type OrgMemberListQuery = z.infer; + +export const invitationStatusSchema = z.enum([ + "pending", + "accepted", + "cancelled", + "expired", +]); + +export const invitationListQuerySchema = directoryListQuerySchema.extend({ + status: invitationStatusSchema.optional(), +}); + +export type InvitationListQuery = z.infer; + +/** + * `POST /v1/org/invitations` body. The email is trimmed and lowercased BEFORE + * the format check: the stored value is the accept-time match key (accept + * compares the visitor's session email against it case-insensitively), so the + * normalization must happen at the door, not per comparison site. + */ +export const createInvitationSchema = z.object({ + email: z.string().trim().toLowerCase().pipe(z.email().max(255)), + role: orgMemberRoleSchema, +}); + +export type CreateInvitationInput = z.infer; + +/** + * `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request — + * either a lifecycle change (`status`) or a role change (`role`). A body + * carrying both or neither is rejected rather than silently applying one: + * the two changes have different invariants and different audit metadata, so + * a combined write would be ambiguous to authorize and to read back. + */ +export type UpdateOrgMemberInput = + | { status: z.infer } + | { role: z.infer }; + +export const updateOrgMemberSchema = z + .object({ + status: orgMemberStatusSchema.optional(), + role: orgMemberRoleSchema.optional(), + }) + .transform((body, ctx): UpdateOrgMemberInput => { + if (body.status !== undefined && body.role === undefined) { + return { status: body.status }; + } + if (body.role !== undefined && body.status === undefined) { + return { role: body.role }; + } + ctx.addIssue({ + code: "custom", + message: "Provide exactly one of `status` or `role`.", + }); + return z.NEVER; + });