feat(orgs): multi-user workspaces — roles, invitations, membership enforcement (#473)

This commit is contained in:
Ben Senescu 2026-08-26 16:54:08 -04:00 committed by GitHub
parent 4d7fb661f0
commit ea162a4391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 18390 additions and 258 deletions

View File

@ -37,6 +37,7 @@
# LOOPS_API_KEY=replace-with-your-loops-api-key
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
# LOOPS_TRANSACTIONAL_INVITATION_ID=replace-with-your-loops-invitation-template-id
# Optional in self-hosted modes. Required if you want AI features like SAM,
# the in-app SEO agent. Create a key at https://openrouter.ai/settings/keys.

View File

@ -26,6 +26,7 @@ DATABASE_PROVIDER=postgres
# LOOPS_API_KEY=
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=
# LOOPS_TRANSACTIONAL_INVITATION_ID=
# TURNSTILE_SECRET_KEY=
# TURNSTILE_SITE_KEY=

View File

@ -283,6 +283,9 @@ const dataEnv = {
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar(
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
),
LOOPS_TRANSACTIONAL_INVITATION_ID: optionalVar(
"LOOPS_TRANSACTIONAL_INVITATION_ID",
),
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),

View File

@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "last_active_organization_id" text;

View File

@ -0,0 +1 @@
CREATE UNIQUE INDEX "member_organizationId_userId_uidx" ON "member" USING btree ("organization_id","user_id");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -148,6 +148,20 @@
"when": 1787099999115,
"tag": "0020_project_memory",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1787594239250,
"tag": "0021_user_last_active_organization",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1787773500453,
"tag": "0022_third_supernaut",
"breakpoints": true
}
]
}

View File

@ -0,0 +1 @@
ALTER TABLE `user` ADD `last_active_organization_id` text;

View File

@ -0,0 +1 @@
CREATE UNIQUE INDEX `member_organizationId_userId_uidx` ON `member` (`organization_id`,`user_id`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -302,6 +302,20 @@
"when": 1787099999115,
"tag": "0042_project_memory",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1787594237405,
"tag": "0043_user_last_active_organization",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1787773498579,
"tag": "0044_outstanding_sage",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,95 @@
# 0011 — Multi-user organizations
Decisions behind PR #473 (multi-user organizations). Hosted mode only; self-host
never mounts the organization endpoints and delegated identities resolve as
implicit owners.
## Roles
Better Auth access control with three app statements on top of the built-ins
(`src/lib/org-permissions.ts`):
- `billing:manage`**owner only** (subscribe, top-up, portal, cancel).
- `project:create/delete` and `integration:manage` (GSC/GA4 connect,
disconnect) — owner + admin.
- **Every invitee is an admin** (deliberate v1 call, confirmed twice): full
access to each project except billing. The `member` role is defined in code
but not invitable — enforced server-side in `beforeCreateInvitation`, which
also blocks owner-role invites (an owner minting a second owner and leaving
would re-mint a fresh org + free grant at next sign-in).
Roles are resolved from the member row per request and never baked into
sessions or tokens.
## Membership invariants
- Every user has at least one org once they use the app; org resolution
(`resolveActiveHostedOrganization`) creates a default organization on demand.
- **Invitations are the only path to multi-org membership**: users cannot
create additional orgs (`allowUserToCreateOrganization: false`) and cannot
delete orgs (`disableOrganizationDeletion: true` — deletion cascades all
data and would re-mint free-plan grants; it stays a support action).
- **Invite sign-ups get no personal organization.** The session-create hook
(`resolveSignInHostedOrganization`) defers default-organization creation for a
membership-less user with a pending invitation, so they land in exactly the
inviter's org. Abandoned/declined invites self-heal: the request path still
creates a organization on the next app visit. Existing users keep their
organization and simply gain the new org.
- Active org = `user.lastActiveOrganizationId` (validated against a live
membership) → newest membership → create default. The column is
load-bearing for the organization switcher and deliberately NOT a better-auth
`userAdditionalFields` field (it must not be user-writable via
`/update-user`).
## Authorization
- Every request re-validates the member row; removing a member revokes access
on their next request, including MCP OAuth tokens (per-request membership
401 in the transport, role stamped per request).
- **MCP API keys are user-scoped credentials** (`orgScope: "user"`), not
key→org bindings: project-scoped tools derive the org from the project row
and authorize via the caller's membership in that org, then bill that org.
One key works across all the user's organizations. `list_projects` spans
memberships; `create_project` requires an explicit `organizationId` when the
user belongs to more than one (the error lists the options and instructs
the agent to confirm with the user). OAuth tokens stay pinned to the org
stamped at consent until re-auth.
- Chat (SAM) re-checks membership every turn and fails closed in hosted mode
when the member row is gone — WebSockets authorize at connect time only,
so the per-turn check is what revokes a removed member's open socket.
## Invitations
- 7-day expiry; re-invite of a pending address re-mails the same link with a
refreshed expiry (the Team UI's Resend).
- The invite email is sent by the `sendTeamInvitation` server function, NOT
better-auth's `sendInvitationEmail` callback — better-auth swallows throws
from that callback, so a failed send would read as "sent". Send failures
fail the call visibly; the pending row stays for retry. Consequence: the
raw `/api/auth/organization/invite-member` endpoint creates pending rows
but emails nobody.
- Abuse bounds: 20 pending invitations per org (better-auth
`invitationLimit`) plus KV daily send counters — 5 sends/address, 50/org
(`src/server/auth/invitation-send-limit.ts`). In-memory rate limiting is a
per-isolate no-op on Workers; KV is the only counter that holds.
- Accepting works signed-out through sign-up + email verification and back;
accept sets the active org and skips first-run onboarding (which would
spend org credits and overwrite the shared project's domain).
- Free-plan audit quota is counted per organization, not per user.
## Accepted residuals
- Onboarding completion is stored per user, so an invitee's later personal
organization starts empty with no onboarding.
- `resolveHostedContext` runs twice on owner-only Autumn mutations.
- KV send counters race under concurrency — they are abuse bounds, not exact
quotas.
## Future (explicitly deferred)
- Per-project authorization inside an org (org membership without access to
every project). The single choke points to extend: `withMcpProjectAuth`
(MCP) and the canonical project-access path (web). Tracked as EVE-50's
remaining scope.
- Roles tighter than admin (expose `member`, decide its rank-tracking rights).
- Intentional multi-org creation, org deletion flow, seats.

View File

@ -1,7 +1,10 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import type { LinkOptions } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState, type ComponentType } from "react";
import {
ArrowLeftRight,
Check,
CircleHelp,
CreditCard,
LayoutGrid,
@ -11,6 +14,8 @@ import {
User,
X,
} from "lucide-react";
import { organizationContextQueryOptions } from "@/client/features/team/organizationQueries";
import { switchOrganization } from "@/serverFunctions/organization";
import {
connectNavGroup,
getProjectNavGroups,
@ -228,12 +233,33 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email;
const [isSwitching, setIsSwitching] = useState(false);
const orgContextQuery = useQuery({
...organizationContextQueryOptions(),
enabled: isHostedMode && Boolean(email),
});
const organizations = orgContextQuery.data?.organizations ?? [];
const activeOrganizationId = orgContextQuery.data?.organizationId;
const closeMenu = () => {
closeDropdown();
onNavigate?.();
};
async function handleSwitchOrganization(organizationId: string) {
if (isSwitching || organizationId === activeOrganizationId) return;
setIsSwitching(true);
try {
await switchOrganization({ data: { organizationId } });
// Full reload: every cached query and the project-scoped URL belong to
// the previous organization.
window.location.assign("/");
} catch {
setIsSwitching(false);
}
}
return (
<div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe">
<SidebarNavLink
@ -260,6 +286,38 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
tabIndex={0}
className="dropdown-content z-30 menu mb-1 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{organizations.length > 1 ? (
<>
<li className="menu-title flex flex-row items-center gap-1.5 max-w-full">
<ArrowLeftRight className="h-3 w-3" />
Organization
</li>
{organizations.map((organization) => (
<li key={organization.organizationId}>
<button
type="button"
disabled={isSwitching}
onClick={() =>
void handleSwitchOrganization(
organization.organizationId,
)
}
>
<span className="truncate">
{organization.organizationName}
</span>
{organization.organizationId === activeOrganizationId ? (
<Check className="h-4 w-4 shrink-0" />
) : null}
</button>
</li>
))}
<li
aria-hidden
className="pointer-events-none my-1 h-px bg-base-300 p-0"
/>
</>
) : null}
<li>
<Link to="/settings" onClick={closeMenu}>
<Settings className="h-4 w-4" />

View File

@ -24,7 +24,7 @@ export function WorkspaceMergeBanner() {
mutationFn: () => mergeLegacyWorkspaces(),
onSuccess: ({ mergedWorkspaces }) => {
toast.success(
`Migrated ${mergedWorkspaces} workspace${mergedWorkspaces === 1 ? "" : "s"} into the shared workspace.`,
`Migrated ${mergedWorkspaces} organization${mergedWorkspaces === 1 ? "" : "s"} into the shared organization.`,
);
// The merge changes projects, connections, and the banner's own status —
// refetch everything rather than enumerating keys.
@ -34,7 +34,7 @@ export function WorkspaceMergeBanner() {
toast.error(
getStandardErrorMessage(
error,
"Couldn't migrate the workspaces. Try again.",
"Couldn't migrate the organizations. Try again.",
),
),
});
@ -57,7 +57,7 @@ export function WorkspaceMergeBanner() {
disabled={mergeMutation.isPending}
onClick={() => mergeMutation.mutate()}
>
{mergeMutation.isPending ? "Migrating…" : "Migrate workspaces"}
{mergeMutation.isPending ? "Migrating…" : "Migrate organizations"}
</button>
</div>
);

View File

@ -197,7 +197,7 @@ function DangerSection({
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-base-content/60">
{canArchive
? "Archive this project to remove it from your workspace."
? "Archive this project to remove it from your organization."
: "You can't archive your only project."}
</p>
<button

View File

@ -0,0 +1,96 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { getErrorCode } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { sendTeamInvitation } from "@/serverFunctions/organization";
export function inviteErrorMessage(error: Error) {
const code = getErrorCode(error);
if (code === "RATE_LIMITED") {
return "Invitation limit reached for today. Try again tomorrow.";
}
if (code === "UPSTREAM_UNAVAILABLE") {
return "The invitation was saved but the email couldn't be sent. Use Resend in a moment to retry.";
}
return "We couldn't send that invitation.";
}
export function InviteTeammateModal({
onClose,
onInvited,
}: {
onClose: () => void;
onInvited: () => void;
}) {
const [email, setEmail] = useState("");
// Server function (not authClient.inviteMember): it enforces the daily send
// limits and fails visibly when the invite email doesn't send.
const inviteMutation = useMutation({
mutationFn: (inviteeEmail: string) =>
sendTeamInvitation({ data: { email: inviteeEmail } }),
onSuccess: () => {
captureClientEvent("team:invitation_send");
toast.success("Invitation sent");
onInvited();
onClose();
},
onError: (error: Error) => {
toast.error(inviteErrorMessage(error));
// An email-send failure still creates the pending row — show it.
onInvited();
},
});
return (
<div className="modal modal-open">
<div className="modal-box max-w-md">
<form
onSubmit={(event) => {
event.preventDefault();
const trimmed = email.trim();
if (trimmed) inviteMutation.mutate(trimmed);
}}
>
<h3 className="text-lg font-bold">Invite a teammate</h3>
<p className="mt-2 text-sm text-base-content/60">
They&rsquo;ll join as an Admin with full access to each project
except for billing. The invitation link expires in 7 days.
</p>
<label className="form-control mt-4 w-full">
<span className="label-text pb-1 text-xs text-base-content/60">
Email
</span>
<input
type="email"
className="input input-sm input-bordered w-full"
placeholder="teammate@company.com"
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
required
autoFocus
/>
</label>
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={inviteMutation.isPending || !email.trim()}
>
{inviteMutation.isPending ? "Sending…" : "Send invite"}
</button>
</div>
</form>
</div>
<div className="modal-backdrop" onClick={onClose} />
</div>
);
}

View File

@ -0,0 +1,186 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import {
InviteTeammateModal,
inviteErrorMessage,
} from "@/client/features/team/InviteTeammateModal";
import { organizationContextQueryOptions } from "@/client/features/team/organizationQueries";
import { InvitationRow, MemberRow } from "@/client/features/team/TeamTableRows";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient, useSession } from "@/lib/auth-client";
import { hasOrgPermission } from "@/lib/org-permissions";
import { getTeam, sendTeamInvitation } from "@/serverFunctions/organization";
// The Organization tab of account settings: who has access to the active org.
export function TeamSettings() {
const { data: session } = useSession();
const queryClient = useQueryClient();
const [isInviteOpen, setIsInviteOpen] = useState(false);
const orgContextQuery = useQuery(organizationContextQueryOptions());
const teamQuery = useQuery({
queryKey: ["organization-team", orgContextQuery.data?.organizationId],
queryFn: () => getTeam(),
enabled: orgContextQuery.data?.organizationId !== undefined,
});
const refreshTeam = () =>
queryClient.invalidateQueries({
queryKey: ["organization-team", orgContextQuery.data?.organizationId],
});
// Same server call as inviting: for an already-pending address it re-mails
// the same link with a refreshed expiry.
const resendMutation = useMutation({
mutationFn: (email: string) => sendTeamInvitation({ data: { email } }),
onSuccess: () => {
captureClientEvent("team:invitation_resend");
toast.success("Invitation resent");
void refreshTeam();
},
onError: (error: Error) => {
toast.error(inviteErrorMessage(error));
},
});
const removeMemberMutation = useMutation({
mutationFn: async (memberId: string) => {
const result = await authClient.organization.removeMember({
memberIdOrEmail: memberId,
});
if (result.error) {
throw new Error(result.error.message || "Failed to remove the member");
}
},
onSuccess: () => {
captureClientEvent("team:member_remove");
toast.success("Member removed");
void refreshTeam();
},
onError: (error: Error) => {
toast.error(error.message || "We couldn't remove that member.");
},
});
const cancelInvitationMutation = useMutation({
mutationFn: async (invitationId: string) => {
const result = await authClient.organization.cancelInvitation({
invitationId,
});
if (result.error) {
throw new Error(
result.error.message || "Failed to cancel the invitation",
);
}
},
onSuccess: () => {
captureClientEvent("team:invitation_cancel");
toast.success("Invitation canceled");
void refreshTeam();
},
onError: (error: Error) => {
toast.error(error.message || "We couldn't cancel that invitation.");
},
});
const role = orgContextQuery.data?.role ?? "member";
const canManageTeam = hasOrgPermission(role, { invitation: ["create"] });
// billing:manage is the owner-only statement (organization:delete is
// disabled app-wide, so it would read as a dead capability).
const isOwner = hasOrgPermission(role, { billing: ["manage"] });
const members = teamQuery.data?.members ?? [];
const pendingInvitations = teamQuery.data?.pendingInvitations ?? [];
if (teamQuery.isError) {
return (
<div className="space-y-3">
<p className="text-sm text-base-content/70">
We couldn&rsquo;t load your team right now.
</p>
<button
type="button"
className="btn btn-soft btn-sm"
onClick={() => void teamQuery.refetch()}
>
Try again
</button>
</div>
);
}
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-medium text-base-content/50">Members</h2>
{canManageTeam ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setIsInviteOpen(true)}
>
Invite teammate
</button>
) : null}
</div>
<p className="text-sm text-base-content/60">
Teammates join as Admins. Admins have full access to each project except
for billing.
</p>
{teamQuery.isPending ? (
<div className="flex justify-center py-6">
<span className="loading loading-spinner loading-md" />
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
<th>Member</th>
<th>Role</th>
<th>Status</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<MemberRow
key={member.id}
member={member}
isSelf={member.userId === session?.user?.id}
canManageTeam={canManageTeam}
isOwner={isOwner}
isRemoving={removeMemberMutation.isPending}
onRemove={() => removeMemberMutation.mutate(member.id)}
/>
))}
{pendingInvitations.map((invitation) => (
<InvitationRow
key={invitation.id}
invitation={invitation}
canManageTeam={canManageTeam}
isResending={resendMutation.isPending}
isCanceling={cancelInvitationMutation.isPending}
onResend={() => resendMutation.mutate(invitation.email)}
onCancel={() =>
cancelInvitationMutation.mutate(invitation.id)
}
/>
))}
</tbody>
</table>
</div>
)}
{isInviteOpen ? (
<InviteTeammateModal
onClose={() => setIsInviteOpen(false)}
onInvited={() => void refreshTeam()}
/>
) : null}
</section>
);
}

View File

@ -0,0 +1,178 @@
import { Send, Trash2 } from "lucide-react";
import { PortalMenu } from "@/client/components/PortalMenu";
import { hasOrgPermission } from "@/lib/org-permissions";
const ROLE_LABELS: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
function formatRole(role: string) {
return role
.split(",")
.map((name) => ROLE_LABELS[name.trim()] ?? name.trim())
.join(", ");
}
type Member = {
id: string;
userId: string;
role: string;
user: { name?: string | null; email: string };
};
type Invitation = {
id: string;
email: string;
role?: string | null;
expiresAt: Date | string;
};
export function MemberRow({
member,
isSelf,
canManageTeam,
isOwner,
isRemoving,
onRemove,
}: {
member: Member;
isSelf: boolean;
canManageTeam: boolean;
isOwner: boolean;
isRemoving: boolean;
onRemove: () => void;
}) {
const memberIsOwner = hasOrgPermission(member.role, {
billing: ["manage"],
});
// Owners are protected server-side (only an owner can touch an owner; the
// last owner can't be removed) — don't render controls that would just 403.
const canRemove = canManageTeam && !isSelf && (!memberIsOwner || isOwner);
return (
<tr className="hover">
<td className="max-w-[280px]">
<p className="truncate font-medium" data-ph-mask>
{member.user.name || member.user.email}
{isSelf ? (
<span className="font-normal text-base-content/50"> (you)</span>
) : null}
</p>
<p className="truncate text-xs text-base-content/50" data-ph-mask>
{member.user.email}
</p>
</td>
<td>
<span className="badge badge-ghost badge-sm">
{formatRole(member.role)}
</span>
</td>
<td className="text-xs text-base-content/70">Active</td>
<td>
{canRemove ? (
<PortalMenu
ariaLabel={`Actions for ${member.user.email}`}
menuClassName="w-52"
>
{(close) => (
<li>
<button
className="text-error"
disabled={isRemoving}
onClick={() => {
close();
if (
window.confirm(
`Remove ${member.user.email} from this organization? They lose access immediately.`,
)
) {
onRemove();
}
}}
>
<Trash2 className="size-3.5" />
Remove member
</button>
</li>
)}
</PortalMenu>
) : null}
</td>
</tr>
);
}
export function InvitationRow({
invitation,
canManageTeam,
isResending,
isCanceling,
onResend,
onCancel,
}: {
invitation: Invitation;
canManageTeam: boolean;
isResending: boolean;
isCanceling: boolean;
onResend: () => void;
onCancel: () => void;
}) {
return (
<tr className="hover">
<td className="max-w-[280px]">
<p className="truncate font-medium" data-ph-mask>
{invitation.email}
</p>
</td>
<td>
<span className="badge badge-ghost badge-sm">
{formatRole(invitation.role ?? "member")}
</span>
</td>
<td className="text-xs text-base-content/70">
Invited &middot; expires{" "}
{new Date(invitation.expiresAt).toLocaleDateString()}
</td>
<td>
{canManageTeam ? (
<PortalMenu
ariaLabel={`Actions for the invitation to ${invitation.email}`}
menuClassName="w-52"
>
{(close) => (
<>
<li>
<button
disabled={isResending}
onClick={() => {
close();
onResend();
}}
>
<Send className="size-3.5" />
Resend invitation
</button>
</li>
<li>
<button
className="text-error"
disabled={isCanceling}
onClick={() => {
close();
onCancel();
}}
>
<Trash2 className="size-3.5" />
Cancel invitation
</button>
</li>
</>
)}
</PortalMenu>
) : null}
</td>
</tr>
);
}

View File

@ -0,0 +1,19 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { hasOrgPermission } from "@/lib/org-permissions";
import { getOrganizationContext } from "@/serverFunctions/organization";
export const organizationContextQueryOptions = () =>
queryOptions({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
staleTime: 60 * 1000,
});
// True while loading: the sole-owner workspace is the overwhelmingly common
// case, and every billing surface is enforced server-side anyway — favor not
// flashing "ask your owner" at actual owners. This is a deliberate cosmetic
// choice, not an authorization gate; the server always re-checks billing:manage.
export function useCanManageBilling() {
const { data } = useQuery(organizationContextQueryOptions());
return data ? hasOrgPermission(data.role, { billing: ["manage"] }) : true;
}

View File

@ -23,6 +23,10 @@ export const user = sqliteTable("user", {
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
// The org this user last worked in; seeds session.activeOrganizationId at
// sign-in (validated against a live membership first). Not a FK: an org
// delete must not fail because a user's last-active pointer references it.
lastActiveOrganizationId: text("last_active_organization_id"),
});
export const session = sqliteTable(
@ -137,6 +141,10 @@ export const member = sqliteTable(
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
uniqueIndex("member_organizationId_userId_uidx").on(
table.organizationId,
table.userId,
),
],
);

View File

@ -24,6 +24,10 @@ export const user = pgTable("user", {
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
analyticsOptedOut: boolean("analytics_opted_out"),
// The org this user last worked in; seeds session.activeOrganizationId at
// sign-in (validated against a live membership first). Not a FK: an org
// delete must not fail because a user's last-active pointer references it.
lastActiveOrganizationId: text("last_active_organization_id"),
});
export const session = pgTable(
@ -128,6 +132,10 @@ export const member = pgTable(
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
uniqueIndex("member_organizationId_userId_uidx").on(
table.organizationId,
table.userId,
),
],
);

View File

@ -264,6 +264,8 @@ const REQUIRED_BETTER_AUTH_INDEXES: {
{ table: "organization", columns: ["slug"], unique: true },
{ table: "member", columns: ["organization_id"], unique: false },
{ table: "member", columns: ["user_id"], unique: false },
// Backstop for duplicate memberships (also guarded by beforeAcceptInvitation).
{ table: "member", columns: ["organization_id", "user_id"], unique: true },
{ table: "invitation", columns: ["organization_id"], unique: false },
{ table: "invitation", columns: ["email"], unique: false },
];

1
src/env.d.ts vendored
View File

@ -41,6 +41,7 @@ declare namespace Cloudflare {
LOOPS_API_KEY?: string;
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
LOOPS_TRANSACTIONAL_INVITATION_ID?: string;
AUTUMN_SECRET_KEY?: string;
AUTUMN_WEBHOOK_SECRET?: string;
// Dub referral conversion tracking (hosted only); all Dub code no-ops

View File

@ -7,13 +7,17 @@ import {
} from "better-auth/client/plugins";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { userAdditionalFields } from "@/lib/auth-options";
import { orgAccessControl, orgRoles } from "@/lib/org-permissions";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [
apiKeyClient(),
organizationClient(),
// ac/roles must match the server plugin exactly, otherwise the client's
// synchronous checkRolePermission evaluates against the defaults and
// disagrees with the server.
organizationClient({ ac: orgAccessControl, roles: orgRoles }),
genericOAuthClient(),
inferAdditionalFields({ user: userAdditionalFields }),
],

View File

@ -1,10 +1,17 @@
import { env } from "cloudflare:workers";
import { genericOAuth, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options";
import { orgAccessControl, orgRoles } from "@/lib/org-permissions";
import { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
export function createBaseAuthConfig() {
type OrganizationOptions = NonNullable<Parameters<typeof organization>[0]>;
const INVITATION_EXPIRES_IN_SECONDS = 60 * 60 * 24 * 7;
export function createBaseAuthConfig(options?: {
organization?: Pick<OrganizationOptions, "organizationHooks">;
}) {
return {
...baseAuthOptions,
advanced: {
@ -44,23 +51,21 @@ export function createBaseAuthConfig() {
// server-side at signup via `auth.api.createOrganization({ body: { userId }})`
// — that's a "system action" (no session + userId in body) which better-auth
// exempts from this flag, so the bootstrap keeps working.
//
// invitationLimit: 0 closes the other path to multi-org membership.
// "One user, one workspace" is a billing invariant: MCP API keys bill the
// user's first org, sessions bill the active org — identical only while
// users can't be invited into a second workspace. Remove this when teams
// ship, in the same change that moves API-key requests to project-level
// authz (org derived per tool call from the project; keys stay
// user-scoped, no key→workspace binding).
//
// disableOrganizationDeletion closes the delete side of the same loop:
// POST /api/auth/organization/delete (owner-callable by default) would
// cascade-delete the workspace, and the next request auto-creates a fresh
// org id — a fresh Autumn customer with a fresh credit grant.
organization({
allowUserToCreateOrganization: false,
invitationLimit: 0,
ac: orgAccessControl,
roles: orgRoles,
// No self-serve delete: it would cascade projects/members/activation
// state, strand the org's Autumn customer, and (with the signup
// bootstrap re-minting a fresh org + free grant on next login) act as
// a credit-farming loop. Deletion stays a support action.
disableOrganizationDeletion: true,
invitationExpiresIn: INVITATION_EXPIRES_IN_SECONDS,
// DB-backed bound on outstanding pending invitations per org — the
// only rate control that actually holds on Workers (in-memory rate
// limiting is per-isolate).
invitationLimit: 20,
...options?.organization,
}),
genericOAuth({
config: [

View File

@ -18,7 +18,9 @@ import {
getHostedTurnstileSecretKey,
hasHostedTurnstileConfig,
} from "@/lib/auth-turnstile";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { resolveSignInHostedOrganization } from "@/server/auth/default-hosted-organization";
import { onInvitationAccepted } from "@/server/auth/invited-member";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { captureDubReferralSignup } from "@/server/referrals/dub";
import {
sendHostedPasswordResetEmail,
@ -45,7 +47,72 @@ function createAuth() {
? getHostedBaseUrl()
: "http://localhost";
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig();
const baseAuthConfig = createBaseAuthConfig(
isHostedAuthMode(env.AUTH_MODE)
? {
organization: {
// No sendInvitationEmail here on purpose: better-auth swallows a
// throw from that callback, so a failed send would still read as
// "sent" in the UI. The invite email is sent (and rate limited)
// by the sendTeamInvitation server function instead, which fails
// the call visibly. Side effect worth knowing: hitting the raw
// /api/auth/organization/invite-member endpoint creates a pending
// invitation but emails nobody.
organizationHooks: {
// The invite UI only offers "admin", but the endpoint accepts
// any role string; enforce server-side. This also keeps an
// owner from minting a second owner and leaving — the path
// that would re-mint a fresh org + free-plan grant at next
// sign-in.
beforeCreateInvitation: async ({ invitation }) => {
if (invitation.role !== "admin") {
throw new APIError("BAD_REQUEST", {
message: "Teammates can only be invited as admins.",
});
}
},
beforeAcceptInvitation: async ({ invitation, user }) => {
const existing = await AuthRepository.getMembership(
user.id,
invitation.organizationId,
);
if (existing) {
throw new APIError("BAD_REQUEST", {
message: "You are already a member of this organization.",
});
}
},
// The invite flow only ever mints "admin", but the raw
// update-member-role endpoint accepts any role string and the
// plugin lets an owner grant owner to another member. Owners
// control billing, so a second owner is a billing-escalation
// path (and, if the first owner then leaves, a fresh-org /
// free-grant loop at next sign-in). Ownership transfers stay a
// support action — reject owner here.
beforeUpdateMemberRole: async ({ newRole }) => {
if (
newRole
.split(",")
.map((r) => r.trim())
.includes("owner")
) {
throw new APIError("BAD_REQUEST", {
message:
"The owner role can't be granted from here. Contact support to transfer ownership.",
});
}
},
afterAcceptInvitation: async ({ member: acceptedMember }) => {
await onInvitationAccepted({
userId: acceptedMember.userId,
organizationId: acceptedMember.organizationId,
});
},
},
},
}
: undefined,
);
// Turnstile captcha on signup — hosted only. Enforcement is driven by the
// server-side secret alone so a client build/runtime site-key mismatch cannot
@ -169,9 +236,15 @@ function createAuth() {
session: {
create: {
before: async (session) => {
// Inject Better Auth's createOrganization here so the helper can
// stay reusable without importing auth.ts and creating a cycle.
const organizationId = await getOrCreateDefaultHostedOrganization(
// Runs on every sign-in (each sign-in mints a session row).
// Resolution order: last-active org while still a member → most
// recently joined org → newly created default organization —
// except that a membership-less user with a pending invitation
// gets no organization minted (null active org) so accepting the invite
// leaves them in exactly the inviter's org. Inject Better Auth's
// createOrganization so the helper can stay reusable without
// importing auth.ts and creating a cycle.
const resolved = await resolveSignInHostedOrganization(
session.userId,
(body) => auth.api.createOrganization({ body }),
);
@ -179,7 +252,7 @@ function createAuth() {
return {
data: {
...session,
activeOrganizationId: organizationId,
activeOrganizationId: resolved?.organizationId ?? null,
},
};
},

View File

@ -0,0 +1,65 @@
import { createAccessControl } from "better-auth/plugins/access";
import {
adminAc,
defaultStatements,
memberAc,
ownerAc,
} from "better-auth/plugins/organization/access";
// App-level resources layered on top of better-auth's built-in org statements
// (organization/member/invitation management, which the plugin's own endpoints
// already enforce). Spreading defaultStatements is required — a custom
// statement object otherwise REPLACES the built-in catalog and every plugin
// permission check fails.
const statement = {
...defaultStatements,
// Subscribe, top-ups, Stripe portal, cancel. Owner-only.
billing: ["manage"],
// Create + archive/restore projects. Renames/settings stay open to all.
project: ["create", "delete"],
// GSC/GA4 connect, re-point, disconnect.
integration: ["manage"],
} as const;
export const orgAccessControl = createAccessControl(statement);
export const orgRoles = {
owner: orgAccessControl.newRole({
...ownerAc.statements,
billing: ["manage"],
project: ["create", "delete"],
integration: ["manage"],
}),
admin: orgAccessControl.newRole({
...adminAc.statements,
project: ["create", "delete"],
integration: ["manage"],
}),
// Defined from day one so exposing it later is UI-only; not offered in the
// invite picker yet. Members can view everything and run research, but not
// manage the org, billing, projects, or integrations.
member: orgAccessControl.newRole({
...memberAc.statements,
}),
};
export type OrgPermissionRequest = Partial<{
[K in keyof typeof statement]: Array<(typeof statement)[K][number]>;
}>;
const roleByName = new Map<string, (typeof orgRoles)[keyof typeof orgRoles]>(
Object.entries(orgRoles),
);
// better-auth stores multiple roles as one comma-separated string and ORs
// permission checks across them; mirror that here. Unknown role names fail
// closed.
export function hasOrgPermission(
role: string,
permissions: OrgPermissionRequest,
): boolean {
return role.split(",").some((name) => {
const candidate = roleByName.get(name.trim());
return candidate ? candidate.authorize(permissions).success : false;
});
}

View File

@ -71,6 +71,9 @@ async function resolveDelegatedContext(
// Delegated auth (Cloudflare Access / local) has no unverified state.
emailVerified: true,
organizationId,
// Delegated orgs are one-implicit-user with no member rows; that user has
// full control of their own workspace.
role: "owner",
};
}
@ -89,6 +92,9 @@ export async function resolveSharedWorkspaceContext(
userEmail: ensuredEmail,
emailVerified: true,
organizationId,
// The Access policy is the authorization boundary; everyone it admits has
// full control of the shared workspace.
role: "owner",
};
}

View File

@ -1,6 +1,7 @@
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { getActiveOrganizationId } from "@/lib/auth-session";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types";
@ -28,29 +29,43 @@ export async function resolveHostedContext(
const activeOrganizationId = getActiveOrganizationId(session);
if (activeOrganizationId) {
// The session's activeOrganizationId is only a hint (it can outlive a
// membership: removal, org deletion, cookie cache). The member row is the
// authorization fact and also carries the caller's role.
const membership = await AuthRepository.getMembership(
session.user.id,
activeOrganizationId,
);
if (membership) {
return {
userId: session.user.id,
userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false,
organizationId: activeOrganizationId,
role: membership.role,
};
}
}
// No active org, or a stale one: re-resolve from live memberships (creating
// a default workspace only when the user has none) and repoint the session.
const authApi = getAuth().api;
const organizationId = await getOrCreateDefaultHostedOrganization(
const resolved = await resolveActiveHostedOrganization(
session.user.id,
(body) => authApi.createOrganization({ body }),
);
await authApi.setActiveOrganization({
headers,
body: { organizationId },
body: { organizationId: resolved.organizationId },
});
return {
userId: session.user.id,
userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false,
organizationId,
organizationId: resolved.organizationId,
role: resolved.role,
};
}

View File

@ -12,5 +12,10 @@ export type EnsuredUserContext = {
// gate paid onboarding spend behind verification.
emailVerified: boolean;
organizationId: string;
// The caller's role in organizationId, from their member row (comma-joined
// when multiple; check via hasOrgPermission, never string equality).
// Delegated modes (Cloudflare Access / local) have one implicit user per
// org and no member rows, so they resolve as "owner".
role: string;
project?: EnsuredProject;
};

View File

@ -19,10 +19,12 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AcceptInvitationIdRouteImport } from './routes/accept-invitation.$id'
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppTeamRouteImport } from './routes/_app/team'
import { Route as AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppProjectsRouteImport } from './routes/_app/projects'
@ -30,9 +32,11 @@ import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index'
import { Route as AppSettingsIndexRouteImport } from './routes/_app/settings/index'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
import { Route as AppSettingsOrganizationRouteImport } from './routes/_app/settings/organization'
import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
@ -104,6 +108,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AcceptInvitationIdRoute = AcceptInvitationIdRouteImport.update({
id: '/accept-invitation/$id',
path: '/accept-invitation/$id',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
id: '/subscribe',
path: '/subscribe',
@ -125,6 +134,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({
path: '/sign-in',
getParentRoute: () => AuthRoute,
} as any)
const AppTeamRoute = AppTeamRouteImport.update({
id: '/team',
path: '/team',
getParentRoute: () => AppRouteRoute,
} as any)
const AppSupportRoute = AppSupportRouteImport.update({
id: '/support',
path: '/support',
@ -162,6 +176,11 @@ const AuthenticatedOnboardingIndexRoute =
path: '/onboarding/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AppSettingsIndexRoute = AppSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppSettingsRoute,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$',
path: '/api/autumn/$',
@ -178,6 +197,11 @@ const AuthenticatedOnboardingChatRoute =
path: '/onboarding/chat',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
id: '/organization',
path: '/organization',
getParentRoute: () => AppSettingsRoute,
} as any)
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
id: '/help/openrouter-api-key',
path: '/help/openrouter-api-key',
@ -323,19 +347,23 @@ export interface FileRoutesByFullPath {
'/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute
'/settings': typeof AppSettingsRouteWithChildren
'/support': typeof AppSupportRoute
'/team': typeof AppTeamRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/settings/': typeof AppSettingsIndexRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -369,18 +397,21 @@ export interface FileRoutesByTo {
'/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute
'/team': typeof AppTeamRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/settings': typeof AppSettingsIndexRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -415,20 +446,24 @@ export interface FileRoutesById {
'/_app/ai': typeof AppAiRoute
'/_app/billing': typeof AppBillingRoute
'/_app/projects': typeof AppProjectsRoute
'/_app/settings': typeof AppSettingsRoute
'/_app/settings': typeof AppSettingsRouteWithChildren
'/_app/support': typeof AppSupportRoute
'/_app/team': typeof AppTeamRoute
'/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute
'/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/_app/settings/organization': typeof AppSettingsOrganizationRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/_app/settings/': typeof AppSettingsIndexRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -466,17 +501,21 @@ export interface FileRouteTypes {
| '/projects'
| '/settings'
| '/support'
| '/team'
| '/sign-in'
| '/sign-up'
| '/oauth-consent'
| '/subscribe'
| '/accept-invitation/$id'
| '/api/health'
| '/p/$projectId'
| '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/settings/organization'
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/settings/'
| '/onboarding/'
| '/p/$projectId/audit'
| '/p/$projectId/backlinks'
@ -510,18 +549,21 @@ export interface FileRouteTypes {
| '/ai'
| '/billing'
| '/projects'
| '/settings'
| '/support'
| '/team'
| '/sign-in'
| '/sign-up'
| '/oauth-consent'
| '/subscribe'
| '/accept-invitation/$id'
| '/api/health'
| '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/settings/organization'
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/settings'
| '/onboarding'
| '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup'
@ -557,18 +599,22 @@ export interface FileRouteTypes {
| '/_app/projects'
| '/_app/settings'
| '/_app/support'
| '/_app/team'
| '/_auth/sign-in'
| '/_auth/sign-up'
| '/_authenticated/oauth-consent'
| '/_authenticated/subscribe'
| '/accept-invitation/$id'
| '/api/health'
| '/_app/'
| '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key'
| '/_app/help/openrouter-api-key'
| '/_app/settings/organization'
| '/_authenticated/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
| '/_app/settings/'
| '/_authenticated/onboarding/'
| '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks'
@ -603,6 +649,7 @@ export interface RootRouteChildren {
ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
@ -682,6 +729,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/accept-invitation/$id': {
id: '/accept-invitation/$id'
path: '/accept-invitation/$id'
fullPath: '/accept-invitation/$id'
preLoaderRoute: typeof AcceptInvitationIdRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/subscribe': {
id: '/_authenticated/subscribe'
path: '/subscribe'
@ -710,6 +764,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSignInRouteImport
parentRoute: typeof AuthRoute
}
'/_app/team': {
id: '/_app/team'
path: '/team'
fullPath: '/team'
preLoaderRoute: typeof AppTeamRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/support': {
id: '/_app/support'
path: '/support'
@ -759,6 +820,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_app/settings/': {
id: '/_app/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AppSettingsIndexRouteImport
parentRoute: typeof AppSettingsRoute
}
'/api/autumn/$': {
id: '/api/autumn/$'
path: '/api/autumn/$'
@ -780,6 +848,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_app/settings/organization': {
id: '/_app/settings/organization'
path: '/organization'
fullPath: '/settings/organization'
preLoaderRoute: typeof AppSettingsOrganizationRouteImport
parentRoute: typeof AppSettingsRoute
}
'/_app/help/openrouter-api-key': {
id: '/_app/help/openrouter-api-key'
path: '/help/openrouter-api-key'
@ -951,12 +1026,27 @@ declare module '@tanstack/react-router' {
}
}
interface AppSettingsRouteChildren {
AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute
AppSettingsIndexRoute: typeof AppSettingsIndexRoute
}
const AppSettingsRouteChildren: AppSettingsRouteChildren = {
AppSettingsOrganizationRoute: AppSettingsOrganizationRoute,
AppSettingsIndexRoute: AppSettingsIndexRoute,
}
const AppSettingsRouteWithChildren = AppSettingsRoute._addFileChildren(
AppSettingsRouteChildren,
)
interface AppRouteRouteChildren {
AppAiRoute: typeof AppAiRoute
AppBillingRoute: typeof AppBillingRoute
AppProjectsRoute: typeof AppProjectsRoute
AppSettingsRoute: typeof AppSettingsRoute
AppSettingsRoute: typeof AppSettingsRouteWithChildren
AppSupportRoute: typeof AppSupportRoute
AppTeamRoute: typeof AppTeamRoute
AppIndexRoute: typeof AppIndexRoute
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
@ -966,8 +1056,9 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppAiRoute: AppAiRoute,
AppBillingRoute: AppBillingRoute,
AppProjectsRoute: AppProjectsRoute,
AppSettingsRoute: AppSettingsRoute,
AppSettingsRoute: AppSettingsRouteWithChildren,
AppSupportRoute: AppSupportRoute,
AppTeamRoute: AppTeamRoute,
AppIndexRoute: AppIndexRoute,
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
@ -1123,6 +1214,7 @@ const rootRouteChildren: RootRouteChildren = {
VerifyEmailRoute: VerifyEmailRoute,
Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
AcceptInvitationIdRoute: AcceptInvitationIdRoute,
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute,

View File

@ -3,6 +3,7 @@ import { useCustomer } from "autumn-js/react";
import { useState } from "react";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
@ -43,6 +44,10 @@ function BillingPage() {
},
});
// Subscription changes are owner-only; other members see balances but are
// pointed at the owner instead of checkout (the server enforces this too).
const canManageBilling = useCanManageBilling();
const planStatus = getCustomerPlanStatus(customerQuery.data);
const isFreePlan = planStatus === "free";
const billingRouteState = getBillingRouteState({
@ -174,7 +179,12 @@ function BillingPage() {
</span>
</div>
{isFreePlan ? (
{!canManageBilling ? (
<p className="border-t border-base-300 pt-3 text-sm text-base-content/60">
Only the organization owner can change the plan or buy credits.
Ask them if you need more.
</p>
) : isFreePlan ? (
<div className="space-y-3 border-t border-base-300 pt-3">
<div className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium">Base Plan</span>
@ -230,8 +240,8 @@ function BillingPage() {
)}
</div>
{/* Buy credits card — paid plan only */}
{!isFreePlan ? (
{/* Buy credits card — paid plan only, owner-only */}
{!isFreePlan && canManageBilling ? (
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div>
<span className="font-semibold">Buy credits</span>

View File

@ -76,7 +76,7 @@ function IndexRedirect() {
return (
<div className="flex items-center justify-center h-full p-4">
<UnauthenticatedErrorCard
message="Please sign in to access your OpenSEO workspace."
message="Please sign in to access your OpenSEO organization."
onRetry={() => {
void refetch();
}}

View File

@ -38,8 +38,8 @@ function ProjectsPage() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
<p className="mt-1 text-sm text-base-content/60">
Each project is a separate workspace with its own Search Console,
rank tracking, and audits.
Each project has its own Search Console, rank tracking, and
audits.
</p>
</div>
<button

View File

@ -1,134 +1,48 @@
import { createFileRoute } from "@tanstack/react-router";
import { Monitor, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
import { authClient, useSession } from "@/lib/auth-client";
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { version } from "../../../package.json";
export const Route = createFileRoute("/_app/settings")({
component: SettingsPage,
component: SettingsLayout,
});
const THEME_OPTIONS: {
value: ThemePreference;
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
// Account-level settings, tabbed like project settings. Personal = the
// signed-in user (theme, API keys, analytics); Organization = the active org
// (team). Billing keeps its own page — it's linked from paywalls all over.
function SettingsLayout() {
const tabs = [
{ to: "/settings" as const, label: "Personal", exact: true },
// Self-host has no memberships — the organization tab would 404.
...(isHostedClientAuthMode()
? [{ to: "/settings/organization" as const, label: "Organization" }]
: []),
];
function SettingsPage() {
const isHosted = isHostedClientAuthMode();
const { themePreference, setThemePreference } = useThemePreference();
const { data: session, isPending: isSessionPending } = useSession();
const [isSaving, setIsSaving] = useState(false);
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
async function updateAnalyticsPreference(enabled: boolean) {
setIsSaving(true);
try {
const result = await authClient.updateUser({
analyticsOptedOut: !enabled,
});
if (result.error) {
toast.error("We couldn't update your analytics setting.");
} else {
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
}
} catch {
toast.error("We couldn't update your analytics setting.");
} finally {
setIsSaving(false);
}
}
return (
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
<div className="mx-auto max-w-3xl space-y-10">
<div className="h-full overflow-auto bg-base-100">
<div className="mx-auto w-full max-w-4xl space-y-8 p-4 py-8 pb-24 sm:p-6 md:py-12 md:pb-12">
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Appearance
</h2>
<div className="flex items-center justify-between gap-6">
<span className="text-sm">Theme</span>
<div
role="radiogroup"
aria-label="Theme preference"
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
>
{THEME_OPTIONS.map((option) => {
const isActive = option.value === themePreference;
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={option.label}
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
isActive
? "bg-base-100 text-base-content shadow-sm"
: "text-base-content/50 hover:text-base-content/80"
}`}
onClick={() => setThemePreference(option.value)}
>
<Icon className="size-4" />
</button>
);
})}
</div>
</div>
</section>
{isHosted ? (
<>
<ApiKeySettings />
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
<div className="flex items-start justify-between gap-6">
<div>
<p className="text-sm">Help improve OpenSEO</p>
<p className="mt-1 text-sm text-base-content/60">
Share analytics and usage data.
</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary"
checked={analyticsEnabled}
disabled={isSessionPending || isSaving || !session?.user}
onChange={(event) => {
void updateAnalyticsPreference(event.currentTarget.checked);
<div role="tablist" className="tabs tabs-border">
{tabs.map((tab) => (
<Link
key={tab.to}
role="tab"
to={tab.to}
activeOptions={{ exact: tab.exact ?? false }}
className="tab"
activeProps={{
className: "tab-active",
"aria-selected": true,
}}
aria-label="Enable product analytics"
/>
inactiveProps={{ "aria-selected": false }}
>
{tab.label}
</Link>
))}
</div>
</section>
</>
) : (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">About</h2>
<div className="flex items-center justify-between gap-6">
<span className="text-sm">Version</span>
<span className="font-mono text-sm text-base-content/60">
v{version}
</span>
</div>
</section>
)}
<Outlet />
</div>
</div>
);

View File

@ -0,0 +1,129 @@
import { createFileRoute } from "@tanstack/react-router";
import { Monitor, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
import { authClient, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { version } from "../../../../package.json";
export const Route = createFileRoute("/_app/settings/")({
component: PersonalSettings,
});
const THEME_OPTIONS: {
value: ThemePreference;
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
];
function PersonalSettings() {
const isHosted = isHostedClientAuthMode();
const { themePreference, setThemePreference } = useThemePreference();
const { data: session, isPending: isSessionPending } = useSession();
const [isSaving, setIsSaving] = useState(false);
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
async function updateAnalyticsPreference(enabled: boolean) {
setIsSaving(true);
try {
const result = await authClient.updateUser({
analyticsOptedOut: !enabled,
});
if (result.error) {
toast.error("We couldn't update your analytics setting.");
} else {
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
}
} catch {
toast.error("We couldn't update your analytics setting.");
} finally {
setIsSaving(false);
}
}
return (
<div className="space-y-10">
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">Appearance</h2>
<div className="flex items-center justify-between gap-6">
<span className="text-sm">Theme</span>
<div
role="radiogroup"
aria-label="Theme preference"
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
>
{THEME_OPTIONS.map((option) => {
const isActive = option.value === themePreference;
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={option.label}
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
isActive
? "bg-base-100 text-base-content shadow-sm"
: "text-base-content/50 hover:text-base-content/80"
}`}
onClick={() => setThemePreference(option.value)}
>
<Icon className="size-4" />
</button>
);
})}
</div>
</div>
</section>
{isHosted ? (
<>
<ApiKeySettings />
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Analytics
</h2>
<div className="flex items-start justify-between gap-6">
<div>
<p className="text-sm">Help improve OpenSEO</p>
<p className="mt-1 text-sm text-base-content/60">
Share analytics and usage data.
</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary"
checked={analyticsEnabled}
disabled={isSessionPending || isSaving || !session?.user}
onChange={(event) => {
void updateAnalyticsPreference(event.currentTarget.checked);
}}
aria-label="Enable product analytics"
/>
</div>
</section>
</>
) : (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">About</h2>
<div className="flex items-center justify-between gap-6">
<span className="text-sm">Version</span>
<span className="font-mono text-sm text-base-content/60">
v{version}
</span>
</div>
</section>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { TeamSettings } from "@/client/features/team/TeamSettings";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_app/settings/organization")({
// Self-host has no memberships or invitations — the better-auth HTTP
// surface isn't even mounted there.
beforeLoad: () => {
if (!isHostedClientAuthMode()) {
throw notFound();
}
},
component: TeamSettings,
});

9
src/routes/_app/team.tsx Normal file
View File

@ -0,0 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
// The old standalone Team page moved into Settings → Organization; keep the
// URL working for bookmarks.
export const Route = createFileRoute("/_app/team")({
beforeLoad: () => {
throw redirect({ to: "/settings/organization" });
},
});

View File

@ -17,7 +17,7 @@ const SCOPES = [
{
icon: KeyRound,
label: "Act on your behalf via MCP",
description: "Run tools and write results back to your workspace.",
description: "Run tools and write results back to your organization.",
},
];

View File

@ -10,6 +10,7 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
import {
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID,
@ -59,6 +60,10 @@ function SubscribePage() {
},
});
// Checkout is owner-only; other members hitting the paywall are pointed at
// their organization owner instead of a Subscribe button that would 403.
const canManageBilling = useCanManageBilling();
// Read managed access from the already-loaded Autumn customer (local, no API
// call) instead of a separate server round-trip. Self-hosted has no Autumn
// customer, so mirror the server's "always granted" behavior there.
@ -268,6 +273,7 @@ function SubscribePage() {
{error ? <p className="text-sm text-error">{error}</p> : null}
{canManageBilling ? (
<button
className="btn btn-soft w-full"
disabled={isAttaching}
@ -275,6 +281,12 @@ function SubscribePage() {
>
{isAttaching ? "Redirecting..." : "Subscribe"}
</button>
) : (
<p className="text-sm text-base-content/60">
Only the organization owner can subscribe. Ask them to upgrade this
organization.
</p>
)}
<p className="text-center text-xs text-base-content/50">
<span

View File

@ -0,0 +1,238 @@
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
import { useState } from "react";
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/accept-invitation/$id")({
beforeLoad: () => {
if (!isHostedClientAuthMode()) {
throw notFound();
}
},
component: AcceptInvitationPage,
});
function AcceptInvitationPage() {
const { id } = Route.useParams();
const { data: session, isPending: isSessionPending } = useSession();
return (
<AuthPageShell>
{isSessionPending ? null : session?.user ? (
<InvitationCard invitationId={id} userEmail={session.user.email} />
) : (
<SignedOutInvitationCard invitationId={id} />
)}
</AuthPageShell>
);
}
// getInvitation requires a session matching the invited email, so a
// logged-out visitor gets a generic shell — no invitation details are
// exposed pre-auth by design.
function SignedOutInvitationCard({ invitationId }: { invitationId: string }) {
const redirect = `/accept-invitation/${invitationId}`;
return (
<AuthPageCard title="You&rsquo;re invited">
<p className="text-sm text-base-content/70">
You&rsquo;ve been invited to join an organization on OpenSEO. Sign in
with the email address that received the invitation to accept it.
</p>
<div className="space-y-2">
<Link
to="/sign-up"
search={{ redirect }}
className="btn btn-soft w-full"
>
Create account
</Link>
<Link
to="/sign-in"
search={{ redirect }}
className="btn btn-ghost w-full"
>
Sign in
</Link>
</div>
</AuthPageCard>
);
}
function InvitationCard({
invitationId,
userEmail,
}: {
invitationId: string;
userEmail: string;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [declined, setDeclined] = useState(false);
const invitationQuery = useQuery({
queryKey: ["invitation", invitationId],
queryFn: async () => {
const result = await authClient.organization.getInvitation({
query: { id: invitationId },
});
if (result.error) {
throw new Error(result.error.message || "Invitation not found");
}
return result.data;
},
retry: false,
});
async function handleAccept() {
setActionError(null);
setIsSubmitting(true);
try {
const accepted = await authClient.organization.acceptInvitation({
invitationId,
});
if (accepted.error) {
setActionError(
accepted.error.message || "We couldn't accept the invitation.",
);
setIsSubmitting(false);
return;
}
// Accepting updates the session row but not the session cookie cache;
// setActive refreshes the cookie so the app opens in the joined org
// immediately instead of after the cache expires.
await authClient.organization.setActive({
organizationId: accepted.data.invitation.organizationId,
});
captureClientEvent("team:invitation_accept");
// Full navigation: every cached query in this tab belongs to the old
// workspace.
window.location.assign("/");
} catch {
setActionError("We couldn't accept the invitation. Please try again.");
setIsSubmitting(false);
}
}
async function handleDecline() {
setActionError(null);
setIsSubmitting(true);
try {
const result = await authClient.organization.rejectInvitation({
invitationId,
});
if (result.error) {
setActionError(
result.error.message || "We couldn't decline the invitation.",
);
setIsSubmitting(false);
return;
}
captureClientEvent("team:invitation_decline");
setDeclined(true);
} catch {
setActionError("We couldn't decline the invitation. Please try again.");
setIsSubmitting(false);
}
}
if (invitationQuery.isPending) {
return (
<AuthPageCard title="Checking invitation...">
<div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" />
</div>
</AuthPageCard>
);
}
if (invitationQuery.isError) {
return (
<AuthPageCard title="Invitation unavailable">
<p className="text-sm text-base-content/70">
This invitation may have expired, been canceled, or belong to a
different email address. You&rsquo;re signed in as{" "}
<span className="font-medium" data-ph-mask>
{userEmail}
</span>
.
</p>
<p className="text-sm text-base-content/70">
If the invitation was sent to another address, sign out and sign back
in with that email. Otherwise ask your teammate to send a new invite.
</p>
<div className="space-y-2">
<button
type="button"
className="btn btn-soft w-full"
onClick={() => {
// Signs out, then lands on sign-in with a redirect back to this
// invitation (staying signed in would bounce straight back here).
signOutAndRedirect();
}}
>
Use a different account
</button>
<Link to="/" className="btn btn-ghost w-full">
Go to dashboard
</Link>
</div>
</AuthPageCard>
);
}
if (declined) {
return (
<AuthPageCard title="Invitation declined">
<p className="text-sm text-base-content/70">
You declined the invitation to join{" "}
<span className="font-medium">
{invitationQuery.data.organizationName}
</span>
.
</p>
<Link to="/" className="btn btn-ghost w-full">
Go to dashboard
</Link>
</AuthPageCard>
);
}
return (
<AuthPageCard title="Join organization">
<p className="text-sm text-base-content/70">
<span className="font-medium" data-ph-mask>
{invitationQuery.data.inviterEmail}
</span>{" "}
invited you to join{" "}
<span className="font-medium">
{invitationQuery.data.organizationName}
</span>{" "}
on OpenSEO.
</p>
{actionError ? <p className="text-sm text-error">{actionError}</p> : null}
<div className="space-y-2">
<button
type="button"
className="btn btn-soft w-full"
disabled={isSubmitting}
onClick={() => void handleAccept()}
>
{isSubmitting ? "Joining..." : "Accept invitation"}
</button>
<button
type="button"
className="btn btn-ghost w-full"
disabled={isSubmitting}
onClick={() => void handleDecline()}
>
Decline
</button>
</div>
</AuthPageCard>
);
}

View File

@ -2,7 +2,29 @@ import { createFileRoute } from "@tanstack/react-router";
import type { autumnHandler } from "autumn-js/fetch";
import { env } from "cloudflare:workers";
import { isHostedAuthMode } from "@/lib/auth-mode";
import { hasOrgPermission } from "@/lib/org-permissions";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
// Autumn routes any org member may call: balance/customer reads that power
// credit meters and usage views. Every other route (attach, updateSubscription,
// openCustomerPortal, setupPayment, referrals, previews, ...) changes the
// org's subscription or payment state and is owner-only — unknown/new routes
// fail closed into the owner-only branch.
const MEMBER_READABLE_AUTUMN_ROUTES = new Set([
"getOrCreateCustomer",
"getEntity",
"listPlans",
"listEvents",
"aggregateEvents",
]);
// The caller's context is resolved exactly once per request, in
// handleAutumnRequest, and shared with Autumn's identify callback through this
// map. Resolving twice (identify re-reads the same headers) would let an
// active-org switch or membership change between the two reads authorize
// against one organization but bill another.
const contextByRequest = new WeakMap<Request, EnsuredUserContext>();
let handlerPromise: Promise<ReturnType<typeof autumnHandler>> | undefined;
@ -13,7 +35,14 @@ function loadHandler() {
({ autumnHandler }) =>
autumnHandler({
identify: async (request) => {
const context = await resolveHostedContext(request.headers);
const context = contextByRequest.get(request);
// identify is only reachable via handleAutumnRequest, which resolves
// first — fail closed rather than re-resolving if that ever breaks.
if (!context) {
throw new Error(
"Autumn identify called without a resolved context",
);
}
return {
customerId: context.organizationId,
@ -30,6 +59,31 @@ async function handleAutumnRequest(request: Request) {
});
}
let context;
try {
context = await resolveHostedContext(request.headers);
} catch {
return Response.json(
{ message: "Authentication required.", code: "unauthenticated" },
{ status: 401 },
);
}
contextByRequest.set(request, context);
const route = new URL(request.url).pathname.split("/").pop() ?? "";
if (
!MEMBER_READABLE_AUTUMN_ROUTES.has(route) &&
!hasOrgPermission(context.role, { billing: ["manage"] })
) {
return Response.json(
{
message: "Only the organization owner can manage billing.",
code: "billing_owner_required",
},
{ status: 403 },
);
}
return (await loadHandler())(request);
}

View File

@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSignInHostedOrganization } from "@/server/auth/default-hosted-organization";
const mocks = vi.hoisted(() => ({
getLastActiveOrganizationId: vi.fn(),
getMembership: vi.fn(),
findNewestMembershipForUser: vi.fn(),
findFirstOrganizationIdForUser: vi.fn(),
getHostedUser: vi.fn(),
hasPendingInvitationForEmail: vi.fn(),
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: mocks,
}));
// Referral pin repair is fire-and-forget KV bookkeeping; mocking it keeps
// `cloudflare:workers` out of this module graph.
vi.mock("@/server/referrals/dub", () => ({
markDubReferredOrganization: vi.fn(),
}));
describe("resolveSignInHostedOrganization", () => {
beforeEach(() => {
mocks.getLastActiveOrganizationId.mockResolvedValue(null);
mocks.findNewestMembershipForUser.mockResolvedValue(null);
mocks.getHostedUser.mockResolvedValue({
id: "user-1",
email: "invitee@example.com",
name: "Invitee",
});
mocks.getMembership.mockResolvedValue({ role: "owner" });
});
// The invite-signup invariant: a brand-new user with a pending invitation
// must NOT get a personal organization auto-minted at sign-in — accepting the
// invite should leave them in exactly the inviter's org.
it("defers organization creation while an invitation is pending", async () => {
mocks.hasPendingInvitationForEmail.mockResolvedValue(true);
const createOrganization = vi.fn();
await expect(
resolveSignInHostedOrganization("user-1", createOrganization),
).resolves.toBeNull();
expect(createOrganization).not.toHaveBeenCalled();
});
it("creates the default organization when no invitation is pending", async () => {
mocks.hasPendingInvitationForEmail.mockResolvedValue(false);
const createOrganization = vi.fn().mockResolvedValue({ id: "org-new" });
await expect(
resolveSignInHostedOrganization("user-1", createOrganization),
).resolves.toMatchObject({ organizationId: "org-new" });
});
// An existing membership always wins — the pending-invite check only
// applies to membership-less users, so inviting an existing user never
// hides their current organization.
it("returns the existing membership without checking invitations", async () => {
mocks.findNewestMembershipForUser.mockResolvedValue({
organizationId: "org-existing",
role: "admin",
});
await expect(
resolveSignInHostedOrganization("user-1", vi.fn()),
).resolves.toEqual({ organizationId: "org-existing", role: "admin" });
expect(mocks.hasPendingInvitationForEmail).not.toHaveBeenCalled();
});
});

View File

@ -20,12 +20,12 @@ type HostedOrganizationCreator = (
function getDefaultHostedOrganizationName(user: HostedUser) {
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
return `${name}'s workspace`;
return `${name}'s organization`;
}
function getDefaultHostedOrganizationSlug(user: HostedUser) {
const slugSource =
user.name?.trim() || user.email.split("@")[0] || "workspace";
user.name?.trim() || user.email.split("@")[0] || "organization";
const suffix = toHex(user.id).slice(0, 12);
return `${slugify(slugSource)}-${suffix}`;
}
@ -65,27 +65,133 @@ async function createDefaultHostedOrganization(
}
}
export async function getOrCreateDefaultHostedOrganization(
type ActiveHostedOrganization = {
organizationId: string;
role: string;
};
async function findExistingActiveOrganization(
userId: string,
): Promise<ActiveHostedOrganization | null> {
const lastActiveOrganizationId =
await AuthRepository.getLastActiveOrganizationId(userId);
if (lastActiveOrganizationId) {
const membership = await AuthRepository.getMembership(
userId,
lastActiveOrganizationId,
);
if (membership) {
return {
organizationId: lastActiveOrganizationId,
role: membership.role,
};
}
}
const newestMembership =
await AuthRepository.findNewestMembershipForUser(userId);
if (newestMembership) {
return {
organizationId: newestMembership.organizationId,
role: newestMembership.role,
};
}
return null;
}
async function createActiveHostedOrganization(
userId: string,
createOrganization: HostedOrganizationCreator,
) {
let organizationId =
await AuthRepository.findFirstOrganizationIdForUser(userId);
if (!organizationId) {
): Promise<ActiveHostedOrganization> {
const hostedUser = await getHostedUser(userId);
organizationId = await createDefaultHostedOrganization(
const organizationId = await createDefaultHostedOrganization(
hostedUser,
createOrganization,
);
// Read the role instead of asserting "owner": a create that lost a race
// falls back to whatever membership won, which may not be owner-created.
const membership = await AuthRepository.getMembership(userId, organizationId);
return { organizationId, role: membership?.role ?? "owner" };
}
// On every session, not just org creation: the signup-time referral pin can
// land after the org exists (email verification from another location, or
// BYPASS_EMAIL_VERIFICATION creating the session inside the signup
// On every resolution, not just org creation: the signup-time referral pin
// can land after the org exists (email verification from another location,
// or BYPASS_EMAIL_VERIFICATION creating the session inside the signup
// transaction before user.create.after hooks flush), so later logins repair
// the org pin. No-ops without a user pin.
// the org pin. No-ops without a user pin, and only ever pins an org the user
// founded — an invitee's membership never counts.
async function repairDubReferralPin(userId: string) {
await markDubReferredOrganization(userId);
return organizationId;
}
// Picks the org a hosted user should be working in: their last-active org if
// they still belong to it, else their most recently joined org (so a fresh
// invite acceptance beats the signup-minted personal organization), else a
// newly created default organization. Used by the stale-session fallback in
// resolveHostedContext.
export async function resolveActiveHostedOrganization(
userId: string,
createOrganization: HostedOrganizationCreator,
): Promise<ActiveHostedOrganization> {
const existing = await findExistingActiveOrganization(userId);
if (existing) {
await repairDubReferralPin(userId);
return existing;
}
const created = await createActiveHostedOrganization(
userId,
createOrganization,
);
await repairDubReferralPin(userId);
return created;
}
// Same as resolveActiveHostedOrganization, but never mints a default
// organization. Used by API-key authentication: a user with no memberships has
// no org to bill or authorize, so credentials should be rejected instead of
// silently spinning up a new workspace.
export async function resolveExistingActiveHostedOrganization(
userId: string,
): Promise<ActiveHostedOrganization | null> {
const existing = await findExistingActiveOrganization(userId);
if (!existing) {
return null;
}
await repairDubReferralPin(userId);
return existing;
}
// Sign-in (session-create hook) variant: same resolution, except a user with
// no memberships and a pending invitation gets NO auto-minted personal
// organization — someone who signs up from an invite link should end up in
// exactly the inviter's org, not that plus an empty personal one. Returns
// null in that window (the session carries no active org until they accept).
// Abandoning the invite flow self-heals: the next app request goes through
// resolveActiveHostedOrganization, which still creates a default organization.
export async function resolveSignInHostedOrganization(
userId: string,
createOrganization: HostedOrganizationCreator,
): Promise<ActiveHostedOrganization | null> {
const existing = await findExistingActiveOrganization(userId);
if (existing) {
await repairDubReferralPin(userId);
return existing;
}
const hostedUser = await getHostedUser(userId);
if (await AuthRepository.hasPendingInvitationForEmail(hostedUser.email)) {
return null;
}
const created = await createActiveHostedOrganization(
userId,
createOrganization,
);
await repairDubReferralPin(userId);
return created;
}

View File

@ -9,7 +9,7 @@ export const SHARED_WORKSPACE_ORGANIZATION_ID = "shared-workspace";
export async function ensureSharedWorkspaceOrganization() {
await AuthRepository.upsertDelegatedOrganization({
id: SHARED_WORKSPACE_ORGANIZATION_ID,
name: "Shared workspace",
name: "Shared organization",
slug: SHARED_WORKSPACE_ORGANIZATION_ID,
});
@ -21,7 +21,7 @@ function getDelegatedOrganizationId(userId: string) {
}
function getDelegatedOrganizationName(email: string, userId: string) {
return `${email.split("@")[0] || userId} workspace`;
return `${email.split("@")[0] || userId} organization`;
}
function getDelegatedOrganizationSlug(email: string, userId: string) {

View File

@ -0,0 +1,52 @@
import { env } from "cloudflare:workers";
import { AppError } from "@/server/lib/errors";
// Daily caps on invitation *emails*. The org plugin's invitationLimit bounds
// pending rows (20), but re-sends to an already-invited address are unbounded
// there — this is the bound on actual sends. KV is the only counter that
// holds across Workers isolates; the fixed UTC-day window keys expire on
// their own. KV writes race under concurrency, so treat these as abuse
// bounds, not exact quotas.
const PER_ADDRESS_DAILY_LIMIT = 5;
const PER_ORG_DAILY_LIMIT = 50;
const WINDOW_TTL_SECONDS = 60 * 60 * 24;
async function bumpDailyCounter(key: string, limit: number) {
const count = Number((await env.KV.get(key)) ?? "0");
if (count >= limit) return false;
await env.KV.put(key, String(count + 1), {
expirationTtl: WINDOW_TTL_SECONDS,
});
return true;
}
export async function consumeInvitationSendBudget(
organizationId: string,
email: string,
) {
const day = new Date().toISOString().slice(0, 10);
const address = email.trim().toLowerCase();
if (
!(await bumpDailyCounter(
`invite-sends:${organizationId}:${day}`,
PER_ORG_DAILY_LIMIT,
))
) {
throw new AppError(
"RATE_LIMITED",
"This organization has reached its daily invitation limit.",
);
}
if (
!(await bumpDailyCounter(
`invite-sends:${organizationId}:${address}:${day}`,
PER_ADDRESS_DAILY_LIMIT,
))
) {
throw new AppError(
"RATE_LIMITED",
"This address has already received several invitations today.",
);
}
}

View File

@ -0,0 +1,37 @@
import { db } from "@/db";
import { userOnboardingAnswers } from "@/db/schema";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
// Runs after an invitation is accepted: point the user's next sign-in at the
// org they just joined, and skip first-run onboarding — the wizard configures
// the org's project (domain, market) and spends org credits, which must not be
// re-run against an already-configured workspace.
export async function onInvitationAccepted(input: {
userId: string;
organizationId: string;
}) {
const now = new Date().toISOString();
await AuthRepository.setLastActiveOrganization(
input.userId,
input.organizationId,
);
await db
.insert(userOnboardingAnswers)
.values({
userId: input.userId,
organizationId: input.organizationId,
completedAt: now,
gscNudgeDismissedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: userOnboardingAnswers.userId,
set: {
completedAt: now,
gscNudgeDismissedAt: now,
updatedAt: now,
},
});
}

View File

@ -0,0 +1,20 @@
import {
hasOrgPermission,
type OrgPermissionRequest,
} from "@/lib/org-permissions";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { AppError } from "@/server/lib/errors";
// Server-side org-role gate for app resources (billing/project/integration).
// Zero-I/O: the role was already resolved from the member row by ensure-user.
export function requireOrgPermission(
context: Pick<EnsuredUserContext, "role">,
permissions: OrgPermissionRequest,
) {
if (!hasOrgPermission(context.role, permissions)) {
throw new AppError(
"FORBIDDEN",
"Your organization role does not allow this action.",
);
}
}

View File

@ -6,7 +6,7 @@ export function slugify(value: string) {
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return slug || "workspace";
return slug || "organization";
}
export function toHex(value: string) {

View File

@ -1,6 +1,21 @@
import { aliasedTable, and, asc, eq, lt, notExists } from "drizzle-orm";
import {
aliasedTable,
and,
asc,
desc,
eq,
gt,
lt,
notExists,
sql,
} from "drizzle-orm";
import { db } from "@/db";
import { member, organization, user as authUser } from "@/db/schema";
import {
invitation,
member,
organization,
user as authUser,
} from "@/db/schema";
type DelegatedOrganizationInput = {
id: string;
@ -80,9 +95,94 @@ async function getHostedUser(userId: string) {
});
}
// The per-request membership check: session.activeOrganizationId is only an
// identity hint, this row is the authorization fact. Returns null when the
// user is not (or no longer) a member.
async function getMembership(userId: string, organizationId: string) {
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(
and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
)
.limit(1);
return membership ?? null;
}
// Fallback active-org choice when there is no valid last-active pointer: the
// most recently joined org, so a just-accepted invitation wins over the
// signup-minted personal workspace.
async function findNewestMembershipForUser(userId: string) {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.orderBy(desc(member.createdAt))
.limit(1);
return membership ?? null;
}
async function listMembershipsForUser(userId: string) {
return db
.select({
organizationId: member.organizationId,
organizationName: organization.name,
role: member.role,
})
.from(member)
.innerJoin(organization, eq(member.organizationId, organization.id))
.where(eq(member.userId, userId))
.orderBy(asc(member.createdAt));
}
// Case-insensitive on purpose: better-auth lowercases the address when it
// mails an invite, but the stored row keeps whatever the inviter typed.
async function hasPendingInvitationForEmail(email: string) {
const [pending] = await db
.select({ id: invitation.id })
.from(invitation)
.where(
and(
eq(sql`lower(${invitation.email})`, email.trim().toLowerCase()),
eq(invitation.status, "pending"),
gt(invitation.expiresAt, new Date()),
),
)
.limit(1);
return pending !== undefined;
}
async function getLastActiveOrganizationId(userId: string) {
const record = await db.query.user.findFirst({
columns: { lastActiveOrganizationId: true },
where: eq(authUser.id, userId),
});
return record?.lastActiveOrganizationId ?? null;
}
async function setLastActiveOrganization(
userId: string,
organizationId: string,
) {
await db
.update(authUser)
.set({ lastActiveOrganizationId: organizationId })
.where(eq(authUser.id, userId));
}
export const AuthRepository = {
upsertDelegatedOrganization,
findFirstOrganizationIdForUser,
findFirstFoundedOrganizationIdForUser,
findNewestMembershipForUser,
getMembership,
listMembershipsForUser,
getLastActiveOrganizationId,
setLastActiveOrganization,
getHostedUser,
hasPendingInvitationForEmail,
} as const;

View File

@ -128,6 +128,37 @@ export async function sendHostedVerificationEmail({
});
}
export async function sendHostedInvitationEmail({
email,
inviteUrl,
organizationName,
inviterName,
inviterEmail,
}: {
email: string;
inviteUrl: string;
organizationName: string;
inviterName: string;
inviterEmail: string;
}) {
// Not part of getHostedAuthEmailConfig(): that trio gates hasHostedAuthConfig
// and adding a new required var there would brick existing deployments.
const apiKey = getRequiredEnv("LOOPS_API_KEY");
const templateId = getRequiredEnv("LOOPS_TRANSACTIONAL_INVITATION_ID");
await sendLoopsTransactionalEmail({
apiKey,
email,
transactionalId: templateId,
dataVariables: {
appName: "OpenSEO",
inviteUrl,
organizationName,
inviterName,
inviterEmail,
},
});
}
export async function sendHostedPasswordResetEmail({
email,
resetUrl,

View File

@ -11,6 +11,7 @@ import {
auditIssues,
auditLighthouseResults,
auditPages,
projects,
} from "@/db/schema";
import { executeInBatches } from "@/db/runBatch";
import { AUDIT_ISSUE_TYPES } from "@/shared/audit-issues";
@ -340,15 +341,19 @@ async function getAuditsByProject(projectId: string) {
return rows.map(({ audit }) => audit);
}
async function getAuditUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({
where: eq(audits.startedByUserId, userId),
columns: {
status: true,
pagesTotal: true,
lighthouseTotal: true,
},
});
// Org-scoped: the free-plan quota belongs to the org (the Autumn customer),
// so usage must aggregate across every member — counting per starting user
// would multiply the free ceiling by the member count.
async function getAuditUsageForOrganization(organizationId: string) {
const rows = await db
.select({
status: audits.status,
pagesTotal: audits.pagesTotal,
lighthouseTotal: audits.lighthouseTotal,
})
.from(audits)
.innerJoin(projects, eq(audits.projectId, projects.id))
.where(eq(projects.organizationId, organizationId));
return {
capacityUnits: rows.reduce(
@ -437,7 +442,7 @@ export const AuditRepository = {
countBlockedPages,
hasPagesForAudit,
getAuditsByProject,
getAuditUsageForUser,
getAuditUsageForOrganization,
getAuditResultsForProject,
getLighthouseResultById,
deleteAuditForProject,

View File

@ -94,8 +94,11 @@ async function startAudit(input: {
// pass the free tier's running-audits gate. Post-insert, each request sees
// at least its own row, so racers can't all slip under the limit; the
// losers roll back via the catch below. Racers at the boundary may all
// abort — the user just retries.
const usage = await AuditRepository.getAuditUsageForUser(input.actorUserId);
// abort — the user just retries. Usage counts per ORGANIZATION, not per
// user: the free ceiling is the org's, so N members don't multiply it.
const usage = await AuditRepository.getAuditUsageForOrganization(
input.billingCustomer.organizationId,
);
if (usage.runningCount > limits.maxRunningAudits) {
throw new AppError("AUDIT_ALREADY_RUNNING");
}

View File

@ -2,6 +2,7 @@ import {
archiveProject,
createProject,
getProjectForOrganization,
getProjectWithOrganization,
listArchivedProjects,
listProjects,
listProjectsEnsuringOne,
@ -22,4 +23,5 @@ export const ProjectService = {
restoreProject,
listArchivedProjects,
getProjectForOrganization,
getProjectWithOrganization,
} as const;

View File

@ -244,3 +244,15 @@ export async function getProjectForOrganization(
return mapProject(project);
}
// Project lookup that reveals which org owns it, for user-scoped credentials
// (MCP API keys): the caller derives the org FROM the project and must then
// authorize the user's membership in that org before acting on the result.
export async function getProjectWithOrganization(projectId: string) {
const project = await ProjectRepository.getProjectById(projectId);
if (!project) return null;
return {
organizationId: project.organizationId,
project: mapProject(project),
};
}

View File

@ -36,6 +36,7 @@ import {
import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import type { ToolAuthContext } from "@/server/mcp/context";
// SAM's read-only view of the project's shared memory. The block has no `set`
@ -267,7 +268,8 @@ export class SamChatAgent extends Think {
// confirmed against a second Autumn read path before refusing — a
// stale check reading here once locked a paying customer out of chat.
const { organizationId } = ctx.project;
if (await isHostedServerAuthMode()) {
const hosted = await isHostedServerAuthMode();
if (hosted) {
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
userId: ctx.row.userId,
userEmail: ctx.userEmail,
@ -285,10 +287,28 @@ export class SamChatAgent extends Think {
const baseUrl =
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
"https://app.openseo.so";
// Delegated/self-host orgs have no member rows — implicit owner. In
// hosted mode a missing member row means the user was removed from the
// workspace; fail closed instead of letting the open socket keep
// owner-level tools (WebSockets authorize at connect time only, so this
// per-turn check is what actually revokes a removed member's chat).
const membership = await AuthRepository.getMembership(
ctx.row.userId,
organizationId,
);
if (hosted && !membership) {
return this.refusalTurn(
"You no longer have access to this organization, so I can't continue this chat.",
);
}
const authContext: ToolAuthContext = {
userId: ctx.row.userId,
userEmail: ctx.userEmail,
organizationId,
role: membership?.role ?? "owner",
// SAM sessions belong to one project's workspace; org context is
// fixed for the session, like an OAuth token's.
orgScope: "pinned",
baseUrl,
clientId: null,
scopes: [MCP_SCOPE],

View File

@ -6,7 +6,7 @@ import type { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transpor
const mocks = vi.hoisted(() => ({
verifyApiKey: vi.fn(),
getHostedUser: vi.fn(),
getOrCreateDefaultHostedOrganization: vi.fn(),
resolveExistingActiveHostedOrganization: vi.fn(),
recordMcpAuthorized: vi.fn(),
handleAuthenticatedOpenSeoMcpRequest:
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
@ -29,8 +29,8 @@ vi.mock("@/server/auth/repositories/AuthRepository", () => ({
}));
vi.mock("@/server/auth/default-hosted-organization", () => ({
getOrCreateDefaultHostedOrganization:
mocks.getOrCreateDefaultHostedOrganization,
resolveExistingActiveHostedOrganization:
mocks.resolveExistingActiveHostedOrganization,
}));
vi.mock("@/server/features/activation/mcpActivation", () => ({
@ -63,7 +63,10 @@ describe("handleMcpApiKeyRequest", () => {
email: "person@example.com",
name: "Person",
});
mocks.getOrCreateDefaultHostedOrganization.mockResolvedValue("org-1");
mocks.resolveExistingActiveHostedOrganization.mockResolvedValue({
organizationId: "org-1",
role: "owner",
});
mocks.recordMcpAuthorized.mockResolvedValue(undefined);
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
new Response("mcp response"),
@ -84,9 +87,8 @@ describe("handleMcpApiKeyRequest", () => {
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
body: { key: "oseo_secret" },
});
expect(mocks.getOrCreateDefaultHostedOrganization).toHaveBeenCalledWith(
expect(mocks.resolveExistingActiveHostedOrganization).toHaveBeenCalledWith(
"user-1",
expect.any(Function),
);
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
@ -100,6 +102,10 @@ describe("handleMcpApiKeyRequest", () => {
userId: "user-1",
userEmail: "person@example.com",
organizationId: "org-1",
role: "owner",
// The key is user-scoped: project tools authorize per call via
// membership in the project's org, not this request-level org.
orgScope: "user",
scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key",
baseUrl: "https://app.openseo.so",
@ -125,6 +131,27 @@ describe("handleMcpApiKeyRequest", () => {
});
});
it("returns 403 when the user has no organization membership", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: true,
error: null,
key: { referenceId: "user-1" },
});
mocks.resolveExistingActiveHostedOrganization.mockResolvedValue(null);
const response = await handleMcpApiKeyRequest(
request({ Authorization: "Bearer oseo_secret" }),
env,
ctx,
);
expect(response?.status).toBe(403);
await expect(response?.json()).resolves.toMatchObject({
error: "account_access_revoked",
});
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
});
it("returns 401 for an invalid key without invoking the transport", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: false,

View File

@ -1,7 +1,7 @@
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { resolveExistingActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
@ -29,6 +29,7 @@ function apiKeyErrorResponse(
const isLimited =
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
const isForbidden = error?.code === "FORBIDDEN";
if (isLimited) {
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
// published error type omits `details`, so narrow at runtime.
@ -49,13 +50,19 @@ function apiKeyErrorResponse(
? error?.code === "RATE_LIMITED"
? "rate_limited"
: "usage_exceeded"
: isForbidden
? "account_access_revoked"
: "invalid_api_key";
const description = isLimited
? typeof error?.message === "string"
? error.message
: "API key request limit reached"
: isForbidden
? typeof error?.message === "string"
? error.message
: "This API key is no longer associated with an organization"
: "The provided API key is invalid, expired, or disabled";
const status = isLimited ? 429 : 401;
const status = isLimited ? 429 : isForbidden ? 403 : 401;
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
// OAuth path); a 429 means we actually cut a caller off, so warn.
@ -103,7 +110,7 @@ export async function handleMcpApiKeyRequest(
// clients (see lib/auth-api-key.ts). Cloudflare's counter is per-colo
// best-effort, which is all this needs to be: credits bound spend, this
// bounds runaway request volume.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared in alchemy.run.ts; env stays unknown through the MCP handler chain
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the binding is declared as a rate limiter in alchemy.run.ts; absent outside hosted prod
const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
if (rateLimit) {
const { success } = await rateLimit.limit({ key: userId });
@ -119,22 +126,31 @@ export async function handleMcpApiKeyRequest(
const user = await AuthRepository.getHostedUser(userId);
if (!user?.email) return apiKeyErrorResponse(null);
// API keys bill the user's default hosted workspace (their first org).
// The hosted product provisions exactly one org per user. Decided
// direction for multi-org: keys stay user-scoped and the org derives from
// the project each tool call names (project-level authz) — not key→org
// binding.
const organizationId = await getOrCreateDefaultHostedOrganization(
userId,
(body) => authApi.createOrganization({ body }),
);
// API keys bill the user's active organization. Keys are user-scoped and
// the org derives from the project each tool call names (project-level
// authz) — not key→org binding. Fail closed if the user has no existing
// memberships; we never mint a default organization for an API key.
const resolved = await resolveExistingActiveHostedOrganization(userId);
if (!resolved) {
return apiKeyErrorResponse({
code: "FORBIDDEN",
message: "API key is not associated with an organization",
});
}
const { organizationId, role } = resolved;
// clientId "api_key" satisfies the hosted transport's fail-closed props
// schema and counts these calls as external MCP clients in telemetry.
// orgScope "user": the key itself is the credential, not a key→org
// binding — project-scoped tools authorize per call via the caller's
// membership in the project's org, and organizationId above is only the
// fallback for tools with no project argument.
const props = createWorkersOAuthMcpProps({
userId,
userEmail: user.email,
organizationId,
role,
orgScope: "user",
baseUrl: getHostedBaseUrl(),
scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key",

View File

@ -7,6 +7,18 @@ export type ToolAuthContext = {
userId: string;
userEmail: string;
organizationId: string;
// Org role for permission gates (owner/admin/member, comma-joined when
// multiple). Hosted: stamped per request from the member row in
// transport.ts. Self-host/delegated: one implicit user per org → "owner".
role: string;
// How tool calls bind to an organization. "pinned": the request's
// organizationId is the authorization boundary — OAuth tokens (org stamped
// at consent) and self-host. "user": the credential is user-scoped (API
// keys) — project-scoped tools derive the org from the project row and
// authorize via the caller's membership in THAT org, so one key works
// across every organization the user belongs to; organizationId is only the
// fallback context for the few tools with no project argument.
orgScope: "pinned" | "user";
scopes: string[];
clientId: string | null;
baseUrl: string;
@ -23,6 +35,12 @@ const applicationAuthContextSchema = z.object({
userId: z.string().min(1),
userEmail: z.string().min(1),
organizationId: z.string().min(1),
// Absent from OAuth grant props (role is stamped per request by the hosted
// transport, never baked into tokens) and from delegated modes (implicit
// owner).
role: z.string().min(1).optional(),
// Absent everywhere except the API-key path; absent means "pinned".
orgScope: z.enum(["pinned", "user"]).optional(),
baseUrl: z.string().url(),
// Compatibility fallback until workers-oauth-provider supplies the verified
// context marker consumed by Agents SDK 0.20.x (the
@ -75,10 +93,23 @@ export function createMcpToolContext(
const authInfo = context.http?.authInfo;
const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null;
const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? [];
const orgScope = applicationAuth.orgScope ?? "pinned";
// Delegated/self-hosted modes have no member rows and a single implicit owner
// per org; "pinned" without a role means owner. API keys ("user" scope) must
// stamp the role from the user's active org membership in api-key-auth.ts.
const role =
applicationAuth.role ?? (orgScope === "pinned" ? "owner" : undefined);
if (!role) {
throw new Error(
"MCP auth context is missing a role for a user-scoped credential",
);
}
return {
auth: {
...applicationAuth,
role,
orgScope,
clientId,
scopes,
},

View File

@ -44,6 +44,8 @@ const authContext: ToolAuthContext = {
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
role: "owner",
orgScope: "pinned",
clientId: "client-1",
scopes: ["mcp"],
baseUrl: "https://app.openseo.so",

View File

@ -3,11 +3,20 @@ import { makeToolContext } from "@/server/mcp/tools/tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
getProjectWithOrganization: vi.fn(),
getMembership: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
getProjectWithOrganization: mocks.getProjectWithOrganization,
},
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: {
getMembership: mocks.getMembership,
},
}));
@ -55,6 +64,8 @@ describe("withMcpProjectAuth", () => {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
role: "owner",
orgScope: "pinned",
clientId: "client_123",
scopes: ["mcp"],
},
@ -105,3 +116,77 @@ describe("withMcpProjectAuth", () => {
expect(handler).not.toHaveBeenCalled();
});
});
// User-scoped credentials (API keys): the org derives from the project and
// access is the caller's membership in that org — never the request's
// organizationId.
describe("withMcpProjectAuth with a user-scoped credential", () => {
const userScopedContext = makeToolContext({ orgScope: "user" });
beforeEach(() => {
vi.resetModules();
mocks.getProjectWithOrganization.mockResolvedValue({
organizationId: "org_other",
project: {
id: "project_123",
name: "Test",
locationCode: 2840,
languageCode: "en",
},
});
mocks.getMembership.mockResolvedValue({ role: "admin" });
});
it("rebinds auth and billing to the project's org and the member's role there", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn<
(
args: { projectId: string },
context: {
auth: { organizationId: string; role: string };
billing: { organizationId: string };
},
) => string
>(() => "ok");
await withMcpProjectAuth(handler)(
{ projectId: "project_123" },
userScopedContext,
);
expect(mocks.getMembership).toHaveBeenCalledWith("user_123", "org_other");
expect(mocks.getProjectForOrganization).not.toHaveBeenCalled();
const [, context] = handler.mock.calls[0];
expect(context.auth.organizationId).toBe("org_other");
expect(context.auth.role).toBe("admin");
expect(context.billing.organizationId).toBe("org_other");
});
it("rejects when the caller has no membership in the project's org", async () => {
mocks.getMembership.mockResolvedValue(null);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
await expect(
withMcpProjectAuth(handler)(
{ projectId: "project_123" },
userScopedContext,
),
).rejects.toThrow("FORBIDDEN");
expect(handler).not.toHaveBeenCalled();
});
it("rejects an unknown project without leaking whether it exists", async () => {
mocks.getProjectWithOrganization.mockResolvedValue(null);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
await expect(
withMcpProjectAuth(handler)({ projectId: "missing" }, userScopedContext),
).rejects.toThrow("FORBIDDEN");
expect(mocks.getMembership).not.toHaveBeenCalled();
expect(handler).not.toHaveBeenCalled();
});
});

View File

@ -1,3 +1,4 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors";
import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
@ -12,6 +13,34 @@ async function requireProjectAccess(
) {
const { baseUrl, ...auth } = toolContext.auth;
// User-scoped credentials (API keys) have no org binding: derive the org
// from the project row, then authorize via the caller's membership in that
// org. The returned auth is rebound to the project's org + the member's
// role there, so billing and every downstream org read follow the project.
if (auth.orgScope === "user") {
const resolved = await ProjectService.getProjectWithOrganization(projectId);
const membership = resolved
? await AuthRepository.getMembership(auth.userId, resolved.organizationId)
: null;
// One FORBIDDEN for both unknown project and non-membership, so probing
// with foreign project ids can't distinguish "exists" from "no access".
if (!resolved || !membership) {
throw new AppError("FORBIDDEN");
}
const projectAuth = {
...auth,
organizationId: resolved.organizationId,
role: membership.role,
};
return {
auth: projectAuth,
baseUrl,
billing: buildBillingCustomer(projectAuth, projectId),
project: resolved.project,
};
}
// Authorize the caller-supplied projectId against the token's organization.
// Assert on the result instead of relying on the lookup throwing, so this
// stays a hard gate even if the service's error behavior ever changes.

View File

@ -4,6 +4,7 @@ import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
createProject: vi.fn(),
listMembershipsForUser: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
@ -12,6 +13,12 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: {
listMembershipsForUser: mocks.listMembershipsForUser,
},
}));
const toolContext = makeToolContext();
describe("create_project MCP tool", () => {
@ -94,4 +101,83 @@ describe("create_project MCP tool", () => {
);
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("rejects a foreign organizationId on a organization-bound (pinned) connection", async () => {
await expect(
createProjectTool.handler(
{ name: "Acme", organizationId: "org_other" },
toolContext,
),
).rejects.toThrow("bound to a single organization");
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("rejects a member role from creating projects", async () => {
const memberContext = makeToolContext({ role: "member" });
await expect(
createProjectTool.handler({ name: "Acme" }, memberContext),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(mocks.createProject).not.toHaveBeenCalled();
});
});
// User-scoped credentials (API keys): the target organization must be
// unambiguous — a single membership resolves implicitly, multiple require an
// explicit, membership-checked organizationId confirmed with the user.
describe("create_project with a user-scoped credential", () => {
const userScopedContext = makeToolContext({ orgScope: "user" });
const memberships = [
{ organizationId: "org_a", organizationName: "Alpha", role: "owner" },
{ organizationId: "org_b", organizationName: "Beta", role: "admin" },
];
beforeEach(() => {
mocks.createProject.mockResolvedValue({
id: "project_new",
name: "Acme",
domain: null,
locationCode: 2840,
languageCode: "en",
});
mocks.listMembershipsForUser.mockResolvedValue(memberships);
});
it("errors with the organization list when no organizationId is given and the user has several", async () => {
await expect(
createProjectTool.handler({ name: "Acme" }, userScopedContext),
).rejects.toThrow(/org_a {2}Alpha[\s\S]*org_b {2}Beta/);
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("creates in the named organization when the user is a member of it", async () => {
await createProjectTool.handler(
{ name: "Acme", organizationId: "org_b" },
userScopedContext,
);
expect(mocks.createProject).toHaveBeenCalledWith("org_b", {
name: "Acme",
});
});
it("rejects a organizationId the user is not a member of", async () => {
await expect(
createProjectTool.handler(
{ name: "Acme", organizationId: "org_stranger" },
userScopedContext,
),
).rejects.toThrow("not a member");
expect(mocks.createProject).not.toHaveBeenCalled();
});
it("resolves implicitly when the user belongs to exactly one organization", async () => {
mocks.listMembershipsForUser.mockResolvedValue([memberships[0]]);
await createProjectTool.handler({ name: "Acme" }, userScopedContext);
expect(mocks.createProject).toHaveBeenCalledWith("org_a", {
name: "Acme",
});
});
});

View File

@ -1,3 +1,5 @@
import { requireOrgPermission } from "@/server/auth/org-gate";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors";
import { mcpResponse } from "@/server/mcp/formatters";
@ -33,10 +35,68 @@ const inputSchema = {
.describe(
'Optional language code (e.g. "en", "fr"). Requires locationCode; derived from the location when omitted.',
),
organizationId: z
.string()
.trim()
.min(1)
.optional()
.describe(
"Organization id to create the project in. Required when the user belongs to more than one organization — omitting it returns the list; confirm the choice with the user before retrying.",
),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
// Which organization gets the project. Pinned credentials (OAuth tokens,
// self-host, SAM) are bound to one org. User-scoped credentials (API keys)
// span organizations, so an ambiguous target is an error listing the options —
// the agent must confirm the choice with the user rather than guessing.
async function resolveTargetOrganization(
auth: Omit<ToolContext["auth"], "baseUrl">,
organizationId: string | undefined,
) {
if (auth.orgScope !== "user") {
if (organizationId && organizationId !== auth.organizationId) {
throw new AppError(
"FORBIDDEN",
"This connection is bound to a single organization — omit organizationId.",
);
}
return { organizationId: auth.organizationId, role: auth.role };
}
const memberships = await AuthRepository.listMembershipsForUser(auth.userId);
if (organizationId) {
const membership = memberships.find(
(candidate) => candidate.organizationId === organizationId,
);
if (!membership) {
throw new AppError(
"FORBIDDEN",
"The user is not a member of that organization.",
);
}
return { organizationId, role: membership.role };
}
if (memberships.length === 1) {
const only = memberships[0];
return { organizationId: only.organizationId, role: only.role };
}
if (memberships.length === 0) {
throw new AppError("FORBIDDEN");
}
const organizationList = memberships
.map(
(membership) =>
`- ${membership.organizationId} ${membership.organizationName}`,
)
.join("\n");
throw new AppError(
"VALIDATION_ERROR",
`The user belongs to ${memberships.length} organizations. Ask the user which organization this project should be created in, then retry with organizationId set:\n${organizationList}`,
);
}
export const createProjectTool = {
name: "create_project",
config: {
@ -65,10 +125,16 @@ export const createProjectTool = {
},
handler: async (args: Args, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth;
const target = await resolveTargetOrganization(auth, args.organizationId);
// Same gate as the createProject server function — MCP and the dashboard
// must agree on who can create projects. The role is the caller's role
// in the TARGET organization, not the request-level one.
requireOrgPermission(target, { project: ["create"] });
// Reuse the app's create schema so the market pair rule (a languageCode
// requires a locationCode) and domain normalization match the dashboard.
// A rejection is bad caller input, not a fault: VALIDATION_ERROR keeps it
// out of error reporting while still naming the bad field.
// out of error reporting while still naming the bad field. organizationId
// is stripped here — it was consumed above.
const parsedInput = createProjectSchema.safeParse(args);
if (!parsedInput.success) {
throw new AppError(
@ -78,7 +144,7 @@ export const createProjectTool = {
}
const input = parsedInput.data;
const project = await ProjectService.createProject(
auth.organizationId,
target.organizationId,
input,
);
return mcpResponse({

View File

@ -1,3 +1,4 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters";
import { type ToolContext } from "@/server/mcp/context";
@ -5,12 +6,42 @@ import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls";
import { z } from "zod";
// The org(s) whose projects the caller can see. Pinned credentials (OAuth
// tokens, self-host) see the bound org; user-scoped credentials (API keys)
// see every organization the user belongs to, labeled so the agent can tell
// same-named projects apart.
async function listVisibleProjects(auth: Omit<ToolContext["auth"], "baseUrl">) {
if (auth.orgScope !== "user") {
const projects = await ProjectService.listProjects(auth.organizationId);
return projects.map((project) => ({
...project,
organization: undefined,
organizationId: undefined,
}));
}
const memberships = await AuthRepository.listMembershipsForUser(auth.userId);
const byOrg = await Promise.all(
memberships.map(async (membership) => {
const projects = await ProjectService.listProjects(
membership.organizationId,
);
return projects.map((project) => ({
...project,
organization: membership.organizationName,
organizationId: membership.organizationId,
}));
}),
);
return byOrg.flat();
}
export const listProjectsTool = {
name: "list_projects",
config: {
title: "List projects",
description:
"Lists all projects in the user's organization. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args.",
"Lists the user's projects. Uses no credits — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain, locationCode, languageCode}; pass the `id` value as `projectId`. locationCode/languageCode are the project's default market — tools fall back to them when a call omits location/language args. When the user belongs to several organizations, each project is labeled with its organization and organizationId (pass that to create_project).",
inputSchema: {} as Record<string, never>,
outputSchema: {
projects: z.array(
@ -22,6 +53,8 @@ export const listProjectsTool = {
locationCode: z.number(),
languageCode: z.string(),
url: z.string(),
organization: z.string().optional(),
organizationId: z.string().optional(),
})
.passthrough(),
),
@ -35,13 +68,13 @@ export const listProjectsTool = {
},
handler: async (_args: Record<string, never>, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth;
const projects = await ProjectService.listProjects(auth.organizationId);
const projects = await listVisibleProjects(auth);
const lines =
projects.length === 0
? ["No projects yet. Create one in the dashboard."]
: projects.map(
(p) =>
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""} market:${p.locationCode}/${p.languageCode}`,
`- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}${p.organization ? ` organization:${p.organization} [${p.organizationId}]` : ""} market:${p.locationCode}/${p.languageCode}`,
);
return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
@ -56,6 +89,8 @@ export const listProjectsTool = {
locationCode: p.locationCode,
languageCode: p.languageCode,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
...(p.organization ? { organization: p.organization } : {}),
...(p.organizationId ? { organizationId: p.organizationId } : {}),
})),
},
});

View File

@ -16,6 +16,12 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
// project-auth imports the repository for user-scoped (API key) credentials;
// unused here (pinned context) but keeps the db out of the module graph.
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: { getMembership: vi.fn() },
}));
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
KeywordResearchService: {
getSavedKeywords: mocks.getSavedKeywords,

View File

@ -8,6 +8,8 @@ export function makeToolContext(
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
role: "owner",
orgScope: "pinned",
clientId: "client_123",
scopes: ["mcp"],
baseUrl: "https://open-seo.test",

View File

@ -9,6 +9,14 @@ vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://open-seo.test",
}));
// The hosted transport re-checks membership per request; mocking the
// repository keeps this test off `cloudflare:workers`-backed db imports.
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: {
getMembership: vi.fn(async () => ({ role: "owner" })),
},
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext: vi.fn(),
}));

View File

@ -17,6 +17,14 @@ const selfHostedAuthMocks = vi.hoisted(() => ({
createMcpHandler: vi.fn(),
}));
const authRepositoryMocks = vi.hoisted(() => ({
getMembership: vi.fn(),
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: authRepositoryMocks,
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext:
selfHostedAuthMocks.resolveCloudflareAccessContext,
@ -201,6 +209,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
});
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
beforeEach(() => {
authRepositoryMocks.getMembership.mockResolvedValue({ role: "owner" });
});
it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
const props = hostedProps();
@ -223,9 +235,14 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
legacy: "reject",
}),
);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith(
props,
);
// The transport stamps the per-request role into the props it hands the
// server; roles are never baked into tokens.
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
[MCP_AUTH_CONTEXT_PROP]: {
...props[MCP_AUTH_CONTEXT_PROP],
role: "owner",
},
});
});
it("routes modern-era requests to the SDK handler", async () => {
@ -288,9 +305,12 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
);
expect(response.status).toBe(200);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith(
props,
);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
[MCP_AUTH_CONTEXT_PROP]: {
...props[MCP_AUTH_CONTEXT_PROP],
role: "owner",
},
});
});
it.each([
@ -335,6 +355,29 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
expect(response.status).toBe(403);
});
it("rejects a token whose user is no longer a member of the granted org", async () => {
// Tokens pin organizationId at consent time; once the membership is gone
// the token must stop working and push the client back through OAuth.
authRepositoryMocks.getMembership.mockResolvedValue(null);
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["mcp"],
});
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(401);
});
it("rejects an OAuth client without the MCP scope", async () => {
const props = hostedProps(["offline_access"]);

View File

@ -20,6 +20,7 @@ import {
} from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { createOpenSeoMcpServer } from "@/server/mcp/server";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
// Mirrors the agents SDK's DEFAULT_CORS_OPTIONS so legacy responses carry the
// same CORS surface as the modern handler's.
@ -176,7 +177,32 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
return withMcpCors(new Response("Invalid Origin", { status: 403 }));
}
return createRequestHandler(result.data, [
// Tokens snapshot organizationId at consent and refresh copies it verbatim,
// so the grant can outlive the membership (member removed, org changed).
// Re-check the member row per request; 401 invalid_token pushes compliant
// clients back through OAuth, where consent stamps their current org.
const authContext = result.data[MCP_AUTH_CONTEXT_PROP];
const membership = await AuthRepository.getMembership(
authContext.userId,
authContext.organizationId,
);
if (!membership) {
return new Response("Organization access revoked", {
status: 401,
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
});
}
// The handler would fall back to the provider-populated ctx.props on its
// own; passing authContext explicitly hands it the schema-validated copy
// (with the per-request role stamped in — roles are never baked into
// tokens) and keeps this path symmetrical with self-hosted, which has no
// ctx.props.
const requestProps = createWorkersOAuthMcpProps({
...authContext,
role: membership.role,
});
return createRequestHandler(requestProps, [
hostedUrl.hostname,
SURFMIND_CHROME_EXTENSION_HOSTNAME,
])(request, env, ctx);

View File

@ -1,5 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { waitUntil } from "cloudflare:workers";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { AuditService } from "@/server/features/audit/services/AuditService";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
@ -77,6 +78,9 @@ export const deleteAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(deleteAuditSchema)
.handler(async ({ data, context }) => {
// Deleting audits frees the org's free-plan capacity ceiling (a SUM over
// audit rows), so it gets the same destructive-action gate as archiving.
requireOrgPermission(context, { project: ["delete"] });
await AuditService.remove(data.auditId, context.projectId);
return { success: true };
});

View File

@ -12,6 +12,7 @@ import {
createSelfHostedGoogleAuthorizationUrl,
GA4_INTEGRATION,
} from "@/server/features/google/selfHostedOAuth";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
@ -162,6 +163,7 @@ export const setGa4Property = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(setPropertySchema)
.handler(async ({ data, context }) => {
requireOrgPermission(context, { integration: ["manage"] });
const connection = await Ga4Service.setProperty({
projectId: context.projectId,
organizationId: context.organizationId,
@ -188,6 +190,7 @@ export const disconnectGa4 = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(projectScopedSchema)
.handler(async ({ context }) => {
requireOrgPermission(context, { integration: ["manage"] });
await Ga4Service.disconnect({
projectId: context.projectId,
userId: context.userId,

View File

@ -8,6 +8,7 @@ import {
createSelfHostedGoogleAuthorizationUrl,
GSC_INTEGRATION,
} from "@/server/features/google/selfHostedOAuth";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
@ -92,6 +93,7 @@ export const setGscSite = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(setSiteSchema)
.handler(async ({ data, context }) => {
requireOrgPermission(context, { integration: ["manage"] });
const connection = await GscService.setSite({
projectId: context.projectId,
organizationId: context.organizationId,
@ -114,6 +116,7 @@ export const disconnectGsc = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(projectScopedSchema)
.handler(async ({ context }) => {
requireOrgPermission(context, { integration: ["manage"] });
await GscService.disconnect({
projectId: context.projectId,
userId: context.userId,

View File

@ -10,6 +10,7 @@ const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
userEmail: z.string(),
emailVerified: z.boolean(),
organizationId: z.string(),
role: z.string(),
project: z.any().optional(),
});

View File

@ -0,0 +1,156 @@
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { z } from "zod";
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { hasOrgPermission } from "@/lib/org-permissions";
import { consumeInvitationSendBudget } from "@/server/auth/invitation-send-limit";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { sendHostedInvitationEmail } from "@/server/email/loops";
import { AppError } from "@/server/lib/errors";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
// The client's source of truth for "who am I in this organization": the active
// org, the caller's role from their member row (resolved server-side by
// ensure-user), and every organization they belong to (for the switcher). Used
// to gate billing/team UI; the server functions and better-auth endpoints
// re-enforce every permission regardless.
export const getOrganizationContext = createServerFn({ method: "GET" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
const memberships = await AuthRepository.listMembershipsForUser(
context.userId,
);
const active = memberships.find(
(membership) => membership.organizationId === context.organizationId,
);
return {
organizationId: context.organizationId,
organizationName: active?.organizationName ?? "Organization",
role: context.role,
organizations: memberships,
};
});
// Team data for the organization settings tab. Pending invitations are
// sensitive (invitee emails, inviter ids) and should only be visible to callers
// who can manage invitations; the server filters them here so the client cannot
// bypass the gate by calling the underlying endpoint directly.
export const getTeam = createServerFn({ method: "GET" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
const fullOrganization = await getAuth().api.getFullOrganization({
headers: getRequest().headers,
query: { organizationId: context.organizationId },
});
if (!fullOrganization) {
throw new AppError("NOT_FOUND");
}
const canViewInvitations = hasOrgPermission(context.role, {
invitation: ["create"],
});
const now = Date.now();
const pendingInvitations = canViewInvitations
? (fullOrganization.invitations ?? []).filter(
(invitation) =>
invitation.status === "pending" &&
new Date(invitation.expiresAt).getTime() > now,
)
: [];
return {
members: fullOrganization.members ?? [],
pendingInvitations,
};
});
const switchOrganizationSchema = z.object({
organizationId: z.string().min(1),
});
// Switch the active organization and persist the choice so the next sign-in
// lands in the same org (session hook reads user.lastActiveOrganizationId).
export const switchOrganization = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(switchOrganizationSchema)
.handler(async ({ data, context }) => {
const membership = await AuthRepository.getMembership(
context.userId,
data.organizationId,
);
if (!membership) {
throw new AppError("NOT_FOUND");
}
await getAuth().api.setActiveOrganization({
headers: getRequest().headers,
body: { organizationId: data.organizationId },
});
await AuthRepository.setLastActiveOrganization(
context.userId,
data.organizationId,
);
return { organizationId: data.organizationId };
});
const sendInvitationSchema = z.object({ email: z.string().email() });
// Invite (or re-invite) a teammate. better-auth creates the pending
// invitation — its server-side checks (inviter must be a member with invite
// permission, admin-only role lock, 20-pending cap) all still run — but the
// email is sent here rather than via the plugin's sendInvitationEmail
// callback: better-auth swallows throws from that callback, so a failed send
// would still read as "sent". Here it fails the call. resend: true re-mails
// an existing pending invitation with the same link and a refreshed expiry.
export const sendTeamInvitation = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(sendInvitationSchema)
.handler(async ({ data, context }) => {
// Defense in depth: fail-closed locally before touching the rate-limit
// budget or calling the plugin, so the budget cannot be exhausted by
// low-privilege callers.
requireOrgPermission(context, { invitation: ["create"] });
await consumeInvitationSendBudget(context.organizationId, data.email);
const invitation = await getAuth().api.createInvitation({
headers: getRequest().headers,
body: {
email: data.email,
role: "admin",
resend: true,
// Bind the invitation to the request's resolved org, not the session's
// active-organization hint, which can be stale after a switch.
organizationId: context.organizationId,
},
});
const [inviter, memberships] = await Promise.all([
AuthRepository.getHostedUser(context.userId),
AuthRepository.listMembershipsForUser(context.userId),
]);
const organizationName =
memberships.find(
(membership) => membership.organizationId === context.organizationId,
)?.organizationName ?? "Organization";
try {
await sendHostedInvitationEmail({
email: data.email,
inviteUrl: `${getHostedBaseUrl()}/accept-invitation/${invitation.id}`,
organizationName,
inviterName: inviter?.name?.trim() || context.userEmail,
inviterEmail: context.userEmail,
});
} catch (error) {
// The invitation row exists and stays pending — surface the send
// failure so the inviter retries instead of assuming it landed.
console.error("Invitation email send failed:", error);
throw new AppError("UPSTREAM_UNAVAILABLE");
}
return { invitationId: invitation.id };
});

View File

@ -1,4 +1,5 @@
import { createServerFn } from "@tanstack/react-start";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import {
requireAuthenticatedContext,
@ -25,9 +26,10 @@ export const getProjects = createServerFn({ method: "POST" })
export const createProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(createProjectSchema)
.handler(async ({ data, context }) =>
ProjectService.createProject(context.organizationId, data),
);
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["create"] });
return ProjectService.createProject(context.organizationId, data);
});
export const updateProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
@ -53,9 +55,10 @@ export const setProjectMarket = createServerFn({ method: "POST" })
export const archiveProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(archiveProjectSchema)
.handler(async ({ data, context }) =>
ProjectService.archiveProject(context.organizationId, data),
);
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["delete"] });
return ProjectService.archiveProject(context.organizationId, data);
});
export const getArchivedProjects = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
@ -66,9 +69,10 @@ export const getArchivedProjects = createServerFn({ method: "POST" })
export const restoreProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(restoreProjectSchema)
.handler(async ({ data, context }) =>
ProjectService.restoreProject(context.organizationId, data),
);
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["delete"] });
return ProjectService.restoreProject(context.organizationId, data);
});
export const getProjectAccess = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)