Compare commits

..

10 Commits

Author SHA1 Message Date
Developer
aefbf86e08 Migrate project to Gitea
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
2026-09-08 21:08:29 +00:00
Jeremy Rivera
3632f40852
Blog: What Broke the $99 Ceiling (#277) 2026-09-03 11:54:34 -04:00
Jeremy Rivera
5b242b225a
Blog: Two Surfaces, Two Timelines (#278) 2026-09-03 11:54:23 -04:00
Ben Senescu
ac9ee482d2
Revert "fix(audit): back off and retry on 429 instead of recording the page a…" (#564)
This reverts commit bb099ad65ae9ac50a6d900c5f61bd0a942661333.
2026-08-28 16:16:44 -04:00
Ben Senescu
ad2d28ea6f
fix(ga4): honor full date ranges instead of clamping to 90 days (#563) 2026-08-28 16:15:22 -04:00
Ben Senescu
bb099ad65a
fix(audit): back off and retry on 429 instead of recording the page as blocked (#562) 2026-08-28 15:27:23 -04:00
Ben Senescu
accac73e16
feat(billing): collect business name, address, and tax ID at checkout (#558) 2026-08-27 18:56:26 -04:00
Ben Senescu
b0248f2acd
release: v0.1.7 (#554) 2026-08-27 18:31:03 -04:00
Ben Senescu
749b38118d
fix: show GA4 MCP rows in Claude text output (#553) 2026-08-27 18:02:13 -04:00
Ben Senescu
ea162a4391
feat(orgs): multi-user workspaces — roles, invitations, membership enforcement (#473) 2026-08-26 16:54:08 -04:00
104 changed files with 19174 additions and 390 deletions

View File

@ -37,6 +37,7 @@
# LOOPS_API_KEY=replace-with-your-loops-api-key # LOOPS_API_KEY=replace-with-your-loops-api-key
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id # 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_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, # 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. # 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_API_KEY=
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID= # LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID= # LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=
# LOOPS_TRANSACTIONAL_INVITATION_ID=
# TURNSTILE_SECRET_KEY= # TURNSTILE_SECRET_KEY=
# TURNSTILE_SITE_KEY= # TURNSTILE_SITE_KEY=

View File

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

0
drizzle-kit Normal file
View File

View File

@ -10,6 +10,6 @@ export default defineConfig({
schema: "./src/db/pg/schema.ts", schema: "./src/db/pg/schema.ts",
out: "./drizzle-pg", out: "./drizzle-pg",
dbCredentials: { dbCredentials: {
url: process.env.POSTGRES_DATABASE_URL!, url: "postgres://openseo_user:Metatron2026@localhost:5432/openseo",
}, },
}); });

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, "when": 1787099999115,
"tag": "0020_project_memory", "tag": "0020_project_memory",
"breakpoints": true "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, "when": 1787099999115,
"tag": "0042_project_memory", "tag": "0042_project_memory",
"breakpoints": true "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
} }
] ]
} }

0
open-seo@0.1.7 Normal file
View File

View File

@ -2,11 +2,12 @@
"name": "open-seo", "name": "open-seo",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"version": "0.1.6", "version": "0.1.7",
"type": "module", "type": "module",
"packageManager": "pnpm@10.30.1", "packageManager": "pnpm@10.30.1",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"start": " vite preview --port 3001",
"dev:clear-chat": "rm -rf .wrangler/state/v3/do/open-seo-OnboardingChatAgent", "dev:clear-chat": "rm -rf .wrangler/state/v3/do/open-seo-OnboardingChatAgent",
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",

15
release-notes/v0.1.7.md Normal file
View File

@ -0,0 +1,15 @@
On-demand SERP depth, steadier SAM and clearer MCP answers.
## What's new
- Load deeper SERP results on demand in Keyword Research and MCP
## Fixed
- Search Console Discover and Google News reports work in MCP.
- SAM no longer crashes during tool-heavy answers.
- Older browsers no longer show blank audit and dashboard pages.
- SERP searches with no Google results now return an empty result instead of failing.
- OpenSEO now shows a clear retry message when SEO data is temporarily unavailable.
Full Changelog: https://github.com/every-app/open-seo/compare/v0.1.6...v0.1.7

View File

@ -127,12 +127,14 @@ common input:
| `offset` | Non-negative integer; default 0 | | `offset` | Non-negative integer; default 0 |
With no explicit dates, the range is the last 28 complete days in the GA4 With no explicit dates, the range is the last 28 complete days in the GA4
property time zone. Explicit ranges are inclusive. The report builder caps the property time zone. Explicit ranges are inclusive and honored in full; there is
end at the last complete property day and moves the start forward when the no maximum range. The report builder caps the end at the last complete property
range exceeds 90 days. The response returns requested and resolved dates plus day. The response returns requested and resolved dates plus an
`end_date_clamped` or `start_date_clamped` warnings. Invalid date formats, `end_date_clamped` warning, which the text output also states. The organic
reversed dates, and a single date without its pair return `validation_error` overview trend is capped at 1,000 rows and reports `trend_truncated` (also
before an API call. stated in the text) when a range exceeds that. Invalid date
formats, reversed dates, and a single date without its pair return
`validation_error` before an API call.
Only these tool-specific inputs are accepted: Only these tool-specific inputs are accepted:

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 { Link, useLocation, useNavigate } from "@tanstack/react-router";
import type { LinkOptions } 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 { useEffect, useState, type ComponentType } from "react";
import { import {
ArrowLeftRight,
Check,
CircleHelp, CircleHelp,
CreditCard, CreditCard,
LayoutGrid, LayoutGrid,
@ -11,6 +14,8 @@ import {
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { organizationContextQueryOptions } from "@/client/features/team/organizationQueries";
import { switchOrganization } from "@/serverFunctions/organization";
import { import {
connectNavGroup, connectNavGroup,
getProjectNavGroups, getProjectNavGroups,
@ -228,12 +233,33 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession(); const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email; 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 = () => { const closeMenu = () => {
closeDropdown(); closeDropdown();
onNavigate?.(); 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 ( return (
<div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe"> <div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe">
<SidebarNavLink <SidebarNavLink
@ -260,6 +286,38 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
tabIndex={0} 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" 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> <li>
<Link to="/settings" onClick={closeMenu}> <Link to="/settings" onClick={closeMenu}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />

View File

@ -24,7 +24,7 @@ export function WorkspaceMergeBanner() {
mutationFn: () => mergeLegacyWorkspaces(), mutationFn: () => mergeLegacyWorkspaces(),
onSuccess: ({ mergedWorkspaces }) => { onSuccess: ({ mergedWorkspaces }) => {
toast.success( 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 — // The merge changes projects, connections, and the banner's own status —
// refetch everything rather than enumerating keys. // refetch everything rather than enumerating keys.
@ -34,7 +34,7 @@ export function WorkspaceMergeBanner() {
toast.error( toast.error(
getStandardErrorMessage( getStandardErrorMessage(
error, 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} disabled={mergeMutation.isPending}
onClick={() => mergeMutation.mutate()} onClick={() => mergeMutation.mutate()}
> >
{mergeMutation.isPending ? "Migrating…" : "Migrate workspaces"} {mergeMutation.isPending ? "Migrating…" : "Migrate organizations"}
</button> </button>
</div> </div>
); );

View File

@ -11,7 +11,10 @@ import { useStickToBottom } from "@/client/components/chat/useStickToBottom";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url"; import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; import {
AUTUMN_CHECKOUT_SESSION_PARAMS,
AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat"; import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import { import {
ChatComposer, ChatComposer,
@ -131,6 +134,7 @@ export function OnboardingChatConversation({
planId: AUTUMN_PAID_PLAN_ID, planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"), successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"),
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
}); });
} catch (checkoutErr) { } catch (checkoutErr) {
setCheckoutError( setCheckoutError(

View File

@ -197,7 +197,7 @@ function DangerSection({
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/60">
{canArchive {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."} : "You can't archive your only project."}
</p> </p>
<button <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()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }), 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( export const session = sqliteTable(
@ -137,6 +141,10 @@ export const member = sqliteTable(
(table) => [ (table) => [
index("member_organizationId_idx").on(table.organizationId), index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId), 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()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
analyticsOptedOut: boolean("analytics_opted_out"), 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( export const session = pgTable(
@ -128,6 +132,10 @@ export const member = pgTable(
(table) => [ (table) => [
index("member_organizationId_idx").on(table.organizationId), index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId), 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: "organization", columns: ["slug"], unique: true },
{ table: "member", columns: ["organization_id"], unique: false }, { table: "member", columns: ["organization_id"], unique: false },
{ table: "member", columns: ["user_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: ["organization_id"], unique: false },
{ table: "invitation", columns: ["email"], 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_API_KEY?: string;
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string;
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
LOOPS_TRANSACTIONAL_INVITATION_ID?: string;
AUTUMN_SECRET_KEY?: string; AUTUMN_SECRET_KEY?: string;
AUTUMN_WEBHOOK_SECRET?: string; AUTUMN_WEBHOOK_SECRET?: string;
// Dub referral conversion tracking (hosted only); all Dub code no-ops // Dub referral conversion tracking (hosted only); all Dub code no-ops

View File

@ -7,13 +7,17 @@ import {
} from "better-auth/client/plugins"; } from "better-auth/client/plugins";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog"; import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { userAdditionalFields } from "@/lib/auth-options"; import { userAdditionalFields } from "@/lib/auth-options";
import { orgAccessControl, orgRoles } from "@/lib/org-permissions";
import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { getSignInHrefForLocation } from "@/lib/auth-redirect";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "", baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [ plugins: [
apiKeyClient(), 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(), genericOAuthClient(),
inferAdditionalFields({ user: userAdditionalFields }), inferAdditionalFields({ user: userAdditionalFields }),
], ],

View File

@ -1,10 +1,17 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { genericOAuth, organization } from "better-auth/plugins"; import { genericOAuth, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options"; 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 { GA4_OAUTH_PROVIDER_ID, GA4_OAUTH_SCOPES } from "@/shared/ga4";
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc"; 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 { return {
...baseAuthOptions, ...baseAuthOptions,
advanced: { advanced: {
@ -44,23 +51,21 @@ export function createBaseAuthConfig() {
// server-side at signup via `auth.api.createOrganization({ body: { userId }})` // server-side at signup via `auth.api.createOrganization({ body: { userId }})`
// — that's a "system action" (no session + userId in body) which better-auth // — that's a "system action" (no session + userId in body) which better-auth
// exempts from this flag, so the bootstrap keeps working. // 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({ organization({
allowUserToCreateOrganization: false, 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, 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({ genericOAuth({
config: [ config: [

View File

@ -18,7 +18,9 @@ import {
getHostedTurnstileSecretKey, getHostedTurnstileSecretKey,
hasHostedTurnstileConfig, hasHostedTurnstileConfig,
} from "@/lib/auth-turnstile"; } 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 { captureDubReferralSignup } from "@/server/referrals/dub";
import { import {
sendHostedPasswordResetEmail, sendHostedPasswordResetEmail,
@ -45,7 +47,72 @@ function createAuth() {
? getHostedBaseUrl() ? getHostedBaseUrl()
: "http://localhost"; : "http://localhost";
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; 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 // Turnstile captcha on signup — hosted only. Enforcement is driven by the
// server-side secret alone so a client build/runtime site-key mismatch cannot // server-side secret alone so a client build/runtime site-key mismatch cannot
@ -169,9 +236,15 @@ function createAuth() {
session: { session: {
create: { create: {
before: async (session) => { before: async (session) => {
// Inject Better Auth's createOrganization here so the helper can // Runs on every sign-in (each sign-in mints a session row).
// stay reusable without importing auth.ts and creating a cycle. // Resolution order: last-active org while still a member → most
const organizationId = await getOrCreateDefaultHostedOrganization( // 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, session.userId,
(body) => auth.api.createOrganization({ body }), (body) => auth.api.createOrganization({ body }),
); );
@ -179,7 +252,7 @@ function createAuth() {
return { return {
data: { data: {
...session, ...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. // Delegated auth (Cloudflare Access / local) has no unverified state.
emailVerified: true, emailVerified: true,
organizationId, 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, userEmail: ensuredEmail,
emailVerified: true, emailVerified: true,
organizationId, 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 { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { getActiveOrganizationId } from "@/lib/auth-session"; 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 { AppError } from "@/server/lib/errors";
import type { EnsuredUserContext } from "./types"; import type { EnsuredUserContext } from "./types";
@ -28,29 +29,43 @@ export async function resolveHostedContext(
const activeOrganizationId = getActiveOrganizationId(session); const activeOrganizationId = getActiveOrganizationId(session);
if (activeOrganizationId) { 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 { return {
userId: session.user.id, userId: session.user.id,
userEmail: session.user.email, userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false, emailVerified: session.user.emailVerified ?? false,
organizationId: activeOrganizationId, 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 authApi = getAuth().api;
const organizationId = await getOrCreateDefaultHostedOrganization( const resolved = await resolveActiveHostedOrganization(
session.user.id, session.user.id,
(body) => authApi.createOrganization({ body }), (body) => authApi.createOrganization({ body }),
); );
await authApi.setActiveOrganization({ await authApi.setActiveOrganization({
headers, headers,
body: { organizationId }, body: { organizationId: resolved.organizationId },
}); });
return { return {
userId: session.user.id, userId: session.user.id,
userEmail: session.user.email, userEmail: session.user.email,
emailVerified: session.user.emailVerified ?? false, 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. // gate paid onboarding spend behind verification.
emailVerified: boolean; emailVerified: boolean;
organizationId: string; 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; 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 AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as ApiHealthRouteImport } from './routes/api/health' 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 AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' 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 AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppProjectsRouteImport } from './routes/_app/projects' 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 AppAiRouteImport } from './routes/_app/ai'
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_authenticated.onboarding.index' 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 ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat' 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 AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
@ -104,6 +108,11 @@ const ApiHealthRoute = ApiHealthRouteImport.update({
path: '/api/health', path: '/api/health',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AcceptInvitationIdRoute = AcceptInvitationIdRouteImport.update({
id: '/accept-invitation/$id',
path: '/accept-invitation/$id',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
id: '/subscribe', id: '/subscribe',
path: '/subscribe', path: '/subscribe',
@ -125,6 +134,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({
path: '/sign-in', path: '/sign-in',
getParentRoute: () => AuthRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
const AppTeamRoute = AppTeamRouteImport.update({
id: '/team',
path: '/team',
getParentRoute: () => AppRouteRoute,
} as any)
const AppSupportRoute = AppSupportRouteImport.update({ const AppSupportRoute = AppSupportRouteImport.update({
id: '/support', id: '/support',
path: '/support', path: '/support',
@ -162,6 +176,11 @@ const AuthenticatedOnboardingIndexRoute =
path: '/onboarding/', path: '/onboarding/',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AppSettingsIndexRoute = AppSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppSettingsRoute,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$', id: '/api/autumn/$',
path: '/api/autumn/$', path: '/api/autumn/$',
@ -178,6 +197,11 @@ const AuthenticatedOnboardingChatRoute =
path: '/onboarding/chat', path: '/onboarding/chat',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
id: '/organization',
path: '/organization',
getParentRoute: () => AppSettingsRoute,
} as any)
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({ const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
id: '/help/openrouter-api-key', id: '/help/openrouter-api-key',
path: '/help/openrouter-api-key', path: '/help/openrouter-api-key',
@ -323,19 +347,23 @@ export interface FileRoutesByFullPath {
'/ai': typeof AppAiRoute '/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute '/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRouteWithChildren
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/team': typeof AppTeamRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/settings/': typeof AppSettingsIndexRoute
'/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -369,18 +397,21 @@ export interface FileRoutesByTo {
'/ai': typeof AppAiRoute '/ai': typeof AppAiRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/projects': typeof AppProjectsRoute '/projects': typeof AppProjectsRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/team': typeof AppTeamRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/settings': typeof AppSettingsIndexRoute
'/onboarding': typeof AuthenticatedOnboardingIndexRoute '/onboarding': typeof AuthenticatedOnboardingIndexRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -415,20 +446,24 @@ export interface FileRoutesById {
'/_app/ai': typeof AppAiRoute '/_app/ai': typeof AppAiRoute
'/_app/billing': typeof AppBillingRoute '/_app/billing': typeof AppBillingRoute
'/_app/projects': typeof AppProjectsRoute '/_app/projects': typeof AppProjectsRoute
'/_app/settings': typeof AppSettingsRoute '/_app/settings': typeof AppSettingsRouteWithChildren
'/_app/support': typeof AppSupportRoute '/_app/support': typeof AppSupportRoute
'/_app/team': typeof AppTeamRoute
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute '/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/accept-invitation/$id': typeof AcceptInvitationIdRoute
'/api/health': typeof ApiHealthRoute '/api/health': typeof ApiHealthRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute '/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/_app/settings/organization': typeof AppSettingsOrganizationRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/_app/settings/': typeof AppSettingsIndexRoute
'/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute '/_authenticated/onboarding/': typeof AuthenticatedOnboardingIndexRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -466,17 +501,21 @@ export interface FileRouteTypes {
| '/projects' | '/projects'
| '/settings' | '/settings'
| '/support' | '/support'
| '/team'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/subscribe' | '/subscribe'
| '/accept-invitation/$id'
| '/api/health' | '/api/health'
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/help/openrouter-api-key' | '/help/openrouter-api-key'
| '/settings/organization'
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/settings/'
| '/onboarding/' | '/onboarding/'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
@ -510,18 +549,21 @@ export interface FileRouteTypes {
| '/ai' | '/ai'
| '/billing' | '/billing'
| '/projects' | '/projects'
| '/settings'
| '/support' | '/support'
| '/team'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent' | '/oauth-consent'
| '/subscribe' | '/subscribe'
| '/accept-invitation/$id'
| '/api/health' | '/api/health'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/help/openrouter-api-key' | '/help/openrouter-api-key'
| '/settings/organization'
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/settings'
| '/onboarding' | '/onboarding'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -557,18 +599,22 @@ export interface FileRouteTypes {
| '/_app/projects' | '/_app/projects'
| '/_app/settings' | '/_app/settings'
| '/_app/support' | '/_app/support'
| '/_app/team'
| '/_auth/sign-in' | '/_auth/sign-in'
| '/_auth/sign-up' | '/_auth/sign-up'
| '/_authenticated/oauth-consent' | '/_authenticated/oauth-consent'
| '/_authenticated/subscribe' | '/_authenticated/subscribe'
| '/accept-invitation/$id'
| '/api/health' | '/api/health'
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/_app/help/openrouter-api-key' | '/_app/help/openrouter-api-key'
| '/_app/settings/organization'
| '/_authenticated/onboarding/chat' | '/_authenticated/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/_app/settings/'
| '/_authenticated/onboarding/' | '/_authenticated/onboarding/'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
@ -603,6 +649,7 @@ export interface RootRouteChildren {
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute VerifyEmailRoute: typeof VerifyEmailRoute
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
AcceptInvitationIdRoute: typeof AcceptInvitationIdRoute
ApiHealthRoute: typeof ApiHealthRoute ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
@ -682,6 +729,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHealthRouteImport preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport 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': { '/_authenticated/subscribe': {
id: '/_authenticated/subscribe' id: '/_authenticated/subscribe'
path: '/subscribe' path: '/subscribe'
@ -710,6 +764,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSignInRouteImport preLoaderRoute: typeof AuthSignInRouteImport
parentRoute: typeof AuthRoute parentRoute: typeof AuthRoute
} }
'/_app/team': {
id: '/_app/team'
path: '/team'
fullPath: '/team'
preLoaderRoute: typeof AppTeamRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/support': { '/_app/support': {
id: '/_app/support' id: '/_app/support'
path: '/support' path: '/support'
@ -759,6 +820,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport preLoaderRoute: typeof AuthenticatedOnboardingIndexRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_app/settings/': {
id: '/_app/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AppSettingsIndexRouteImport
parentRoute: typeof AppSettingsRoute
}
'/api/autumn/$': { '/api/autumn/$': {
id: '/api/autumn/$' id: '/api/autumn/$'
path: '/api/autumn/$' path: '/api/autumn/$'
@ -780,6 +848,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
parentRoute: typeof AuthenticatedRoute 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': { '/_app/help/openrouter-api-key': {
id: '/_app/help/openrouter-api-key' id: '/_app/help/openrouter-api-key'
path: '/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 { interface AppRouteRouteChildren {
AppAiRoute: typeof AppAiRoute AppAiRoute: typeof AppAiRoute
AppBillingRoute: typeof AppBillingRoute AppBillingRoute: typeof AppBillingRoute
AppProjectsRoute: typeof AppProjectsRoute AppProjectsRoute: typeof AppProjectsRoute
AppSettingsRoute: typeof AppSettingsRoute AppSettingsRoute: typeof AppSettingsRouteWithChildren
AppSupportRoute: typeof AppSupportRoute AppSupportRoute: typeof AppSupportRoute
AppTeamRoute: typeof AppTeamRoute
AppIndexRoute: typeof AppIndexRoute AppIndexRoute: typeof AppIndexRoute
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
@ -966,8 +1056,9 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppAiRoute: AppAiRoute, AppAiRoute: AppAiRoute,
AppBillingRoute: AppBillingRoute, AppBillingRoute: AppBillingRoute,
AppProjectsRoute: AppProjectsRoute, AppProjectsRoute: AppProjectsRoute,
AppSettingsRoute: AppSettingsRoute, AppSettingsRoute: AppSettingsRouteWithChildren,
AppSupportRoute: AppSupportRoute, AppSupportRoute: AppSupportRoute,
AppTeamRoute: AppTeamRoute,
AppIndexRoute: AppIndexRoute, AppIndexRoute: AppIndexRoute,
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute, AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute, AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
@ -1123,6 +1214,7 @@ const rootRouteChildren: RootRouteChildren = {
VerifyEmailRoute: VerifyEmailRoute, VerifyEmailRoute: VerifyEmailRoute,
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: Char91DotwellKnownChar93OpenaiAppsChallengeRoute:
Char91DotwellKnownChar93OpenaiAppsChallengeRoute, Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
AcceptInvitationIdRoute: AcceptInvitationIdRoute,
ApiHealthRoute: ApiHealthRoute, ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,

View File

@ -3,6 +3,7 @@ import { useCustomer } from "autumn-js/react";
import { useState } from "react"; import { useState } from "react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url"; import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
@ -12,6 +13,7 @@ import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContent
import { getBillingRouteState } from "@/client/features/billing/route-state"; import { getBillingRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { import {
AUTUMN_CHECKOUT_SESSION_PARAMS,
AUTUMN_PAID_PLAN_ID, AUTUMN_PAID_PLAN_ID,
BILLING_ROUTE, BILLING_ROUTE,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
@ -43,6 +45,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 planStatus = getCustomerPlanStatus(customerQuery.data);
const isFreePlan = planStatus === "free"; const isFreePlan = planStatus === "free";
const billingRouteState = getBillingRouteState({ const billingRouteState = getBillingRouteState({
@ -98,6 +104,7 @@ function BillingPage() {
planId: AUTUMN_PAID_PLAN_ID, planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: buildCheckoutSuccessUrl(BILLING_ROUTE), successUrl: buildCheckoutSuccessUrl(BILLING_ROUTE),
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
}); });
} }
@ -174,7 +181,12 @@ function BillingPage() {
</span> </span>
</div> </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="space-y-3 border-t border-base-300 pt-3">
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium">Base Plan</span> <span className="text-sm font-medium">Base Plan</span>
@ -230,8 +242,8 @@ function BillingPage() {
)} )}
</div> </div>
{/* Buy credits card — paid plan only */} {/* Buy credits card — paid plan only, owner-only */}
{!isFreePlan ? ( {!isFreePlan && canManageBilling ? (
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3"> <div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div> <div>
<span className="font-semibold">Buy credits</span> <span className="font-semibold">Buy credits</span>
@ -272,6 +284,7 @@ function BillingPage() {
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: window.location.href, successUrl: window.location.href,
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
featureQuantities: [ featureQuantities: [
{ {
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,

View File

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

View File

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

View File

@ -1,134 +1,48 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Link, Outlet } 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 { isHostedClientAuthMode } from "@/lib/auth-mode";
import { version } from "../../../package.json";
export const Route = createFileRoute("/_app/settings")({ export const Route = createFileRoute("/_app/settings")({
component: SettingsPage, component: SettingsLayout,
}); });
const THEME_OPTIONS: { // Account-level settings, tabbed like project settings. Personal = the
value: ThemePreference; // signed-in user (theme, API keys, analytics); Organization = the active org
label: string; // (team). Billing keeps its own page — it's linked from paywalls all over.
icon: typeof Sun; function SettingsLayout() {
}[] = [ const tabs = [
{ value: "system", label: "System", icon: Monitor }, { to: "/settings" as const, label: "Personal", exact: true },
{ value: "light", label: "Light", icon: Sun }, // Self-host has no memberships — the organization tab would 404.
{ value: "dark", label: "Dark", icon: Moon }, ...(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 ( 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="h-full overflow-auto bg-base-100">
<div className="mx-auto max-w-3xl space-y-10"> <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> <h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<div role="tablist" className="tabs tabs-border">
<section className="space-y-3"> {tabs.map((tab) => (
<h2 className="text-sm font-medium text-base-content/50"> <Link
Appearance key={tab.to}
</h2> role="tab"
<div className="flex items-center justify-between gap-6"> to={tab.to}
<span className="text-sm">Theme</span> activeOptions={{ exact: tab.exact ?? false }}
<div className="tab"
role="radiogroup" activeProps={{
aria-label="Theme preference" className: "tab-active",
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5" "aria-selected": true,
>
{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" inactiveProps={{ "aria-selected": false }}
/> >
{tab.label}
</Link>
))}
</div> </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> </div>
</section>
)} <Outlet />
</div> </div>
</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, icon: KeyRound,
label: "Act on your behalf via MCP", 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,7 +10,9 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { normalizeAuthRedirect } from "@/lib/auth-redirect"; import { normalizeAuthRedirect } from "@/lib/auth-redirect";
import { useCanManageBilling } from "@/client/features/team/organizationQueries";
import { import {
AUTUMN_CHECKOUT_SESSION_PARAMS,
AUTUMN_MANAGED_ACCESS_FEATURE_ID, AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID, AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing"; } from "@/shared/billing";
@ -59,6 +61,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 // 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 // call) instead of a separate server round-trip. Self-hosted has no Autumn
// customer, so mirror the server's "always granted" behavior there. // customer, so mirror the server's "always granted" behavior there.
@ -195,6 +201,7 @@ function SubscribePage() {
planId: AUTUMN_PAID_PLAN_ID, planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: successUrl.toString(), successUrl: successUrl.toString(),
checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS,
}); });
} catch (err) { } catch (err) {
setError( setError(
@ -268,6 +275,7 @@ function SubscribePage() {
{error ? <p className="text-sm text-error">{error}</p> : null} {error ? <p className="text-sm text-error">{error}</p> : null}
{canManageBilling ? (
<button <button
className="btn btn-soft w-full" className="btn btn-soft w-full"
disabled={isAttaching} disabled={isAttaching}
@ -275,6 +283,12 @@ function SubscribePage() {
> >
{isAttaching ? "Redirecting..." : "Subscribe"} {isAttaching ? "Redirecting..." : "Subscribe"}
</button> </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"> <p className="text-center text-xs text-base-content/50">
<span <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 type { autumnHandler } from "autumn-js/fetch";
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { isHostedAuthMode } from "@/lib/auth-mode"; import { isHostedAuthMode } from "@/lib/auth-mode";
import { hasOrgPermission } from "@/lib/org-permissions";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; 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; let handlerPromise: Promise<ReturnType<typeof autumnHandler>> | undefined;
@ -13,7 +35,14 @@ function loadHandler() {
({ autumnHandler }) => ({ autumnHandler }) =>
autumnHandler({ autumnHandler({
identify: async (request) => { 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 { return {
customerId: context.organizationId, 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); 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) { function getDefaultHostedOrganizationName(user: HostedUser) {
const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO"; const name = user.name?.trim() || user.email.split("@")[0] || "OpenSEO";
return `${name}'s workspace`; return `${name}'s organization`;
} }
function getDefaultHostedOrganizationSlug(user: HostedUser) { function getDefaultHostedOrganizationSlug(user: HostedUser) {
const slugSource = 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); const suffix = toHex(user.id).slice(0, 12);
return `${slugify(slugSource)}-${suffix}`; 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, userId: string,
createOrganization: HostedOrganizationCreator, createOrganization: HostedOrganizationCreator,
) { ): Promise<ActiveHostedOrganization> {
let organizationId =
await AuthRepository.findFirstOrganizationIdForUser(userId);
if (!organizationId) {
const hostedUser = await getHostedUser(userId); const hostedUser = await getHostedUser(userId);
organizationId = await createDefaultHostedOrganization( const organizationId = await createDefaultHostedOrganization(
hostedUser, hostedUser,
createOrganization, 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 // On every resolution, not just org creation: the signup-time referral pin
// land after the org exists (email verification from another location, or // can land after the org exists (email verification from another location,
// BYPASS_EMAIL_VERIFICATION creating the session inside the signup // or BYPASS_EMAIL_VERIFICATION creating the session inside the signup
// transaction before user.create.after hooks flush), so later logins repair // 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); 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() { export async function ensureSharedWorkspaceOrganization() {
await AuthRepository.upsertDelegatedOrganization({ await AuthRepository.upsertDelegatedOrganization({
id: SHARED_WORKSPACE_ORGANIZATION_ID, id: SHARED_WORKSPACE_ORGANIZATION_ID,
name: "Shared workspace", name: "Shared organization",
slug: SHARED_WORKSPACE_ORGANIZATION_ID, slug: SHARED_WORKSPACE_ORGANIZATION_ID,
}); });
@ -21,7 +21,7 @@ function getDelegatedOrganizationId(userId: string) {
} }
function getDelegatedOrganizationName(email: string, 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) { 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, "") .replace(/^-+|-+$/g, "")
.slice(0, 48); .slice(0, 48);
return slug || "workspace"; return slug || "organization";
} }
export function toHex(value: string) { 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 { db } from "@/db";
import { member, organization, user as authUser } from "@/db/schema"; import {
invitation,
member,
organization,
user as authUser,
} from "@/db/schema";
type DelegatedOrganizationInput = { type DelegatedOrganizationInput = {
id: string; 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 = { export const AuthRepository = {
upsertDelegatedOrganization, upsertDelegatedOrganization,
findFirstOrganizationIdForUser, findFirstOrganizationIdForUser,
findFirstFoundedOrganizationIdForUser, findFirstFoundedOrganizationIdForUser,
findNewestMembershipForUser,
getMembership,
listMembershipsForUser,
getLastActiveOrganizationId,
setLastActiveOrganization,
getHostedUser, getHostedUser,
hasPendingInvitationForEmail,
} as const; } 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({ export async function sendHostedPasswordResetEmail({
email, email,
resetUrl, resetUrl,

View File

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

View File

@ -94,8 +94,11 @@ async function startAudit(input: {
// pass the free tier's running-audits gate. Post-insert, each request sees // 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 // 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 // losers roll back via the catch below. Racers at the boundary may all
// abort — the user just retries. // abort — the user just retries. Usage counts per ORGANIZATION, not per
const usage = await AuditRepository.getAuditUsageForUser(input.actorUserId); // 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) { if (usage.runningCount > limits.maxRunningAudits) {
throw new AppError("AUDIT_ALREADY_RUNNING"); throw new AppError("AUDIT_ALREADY_RUNNING");
} }

View File

@ -36,7 +36,7 @@ describe("Ga4OrganicOverviewService", () => {
mocks.getByProjectId.mockResolvedValue(connection); mocks.getByProjectId.mockResolvedValue(connection);
}); });
it("returns an equal-length comparison and weekly trend", async () => { it("returns an equal-length comparison and flags a truncated trend", async () => {
mocks.runReport mocks.runReport
.mockResolvedValueOnce({ .mockResolvedValueOnce({
dimensionHeaders: [], dimensionHeaders: [],
@ -93,7 +93,7 @@ describe("Ga4OrganicOverviewService", () => {
]), ]),
}, },
], ],
rowCount: 1, rowCount: 1200,
}); });
const result = await Ga4OrganicOverviewService.getOrganicOverview( const result = await Ga4OrganicOverviewService.getOrganicOverview(
{ {
@ -120,6 +120,7 @@ describe("Ga4OrganicOverviewService", () => {
sessions: 100, sessions: 100,
}); });
expect(result.diagnostics).toEqual([]); expect(result.diagnostics).toEqual([]);
expect(result.warnings).toEqual(["trend_truncated"]);
expect(mocks.runReport).toHaveBeenCalledTimes(3); expect(mocks.runReport).toHaveBeenCalledTimes(3);
}); });

View File

@ -142,7 +142,12 @@ async function getOrganicOverview(
reports: reports.map((report) => report.reportMetadata), reports: reports.map((report) => report.reportMetadata),
}, },
quota: trendReport.quota ?? current.quota, quota: trendReport.quota ?? current.quota,
warnings: dateRange.warnings, warnings: [
...dateRange.warnings,
...(trendReport.totalRowCount > trendReport.rows.length
? ["trend_truncated"]
: []),
],
}; };
} catch (error) { } catch (error) {
mapGa4ReportError(error); mapGa4ReportError(error);

View File

@ -134,7 +134,7 @@ describe("Ga4ReportingService", () => {
}); });
}); });
it("clamps explicit dates and nulls restricted metrics", async () => { it("clamps a future endDate, keeps a long startDate, and nulls restricted metrics", async () => {
mocks.runReport.mockResolvedValue({ mocks.runReport.mockResolvedValue({
...landingHeaders, ...landingHeaders,
rows: [ rows: [
@ -168,10 +168,10 @@ describe("Ga4ReportingService", () => {
); );
expect(result.request.resolvedDateRange).toEqual({ expect(result.request.resolvedDateRange).toEqual({
startDate: "2026-05-08", startDate: "2025-01-01",
endDate: "2026-08-05", endDate: "2026-08-05",
}); });
expect(result.warnings).toEqual(["end_date_clamped", "start_date_clamped"]); expect(result.warnings).toEqual(["end_date_clamped"]);
expect(result.rows[0]?.purchaseRevenue).toBeNull(); expect(result.rows[0]?.purchaseRevenue).toBeNull();
}); });

View File

@ -87,17 +87,12 @@ export function resolveGa4DateRange(
-1, -1,
); );
let endDate = requestedDateRange?.endDate ?? lastCompleteDay; let endDate = requestedDateRange?.endDate ?? lastCompleteDay;
let startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27); const startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27);
const warnings: string[] = []; const warnings: string[] = [];
if (endDate > lastCompleteDay) { if (endDate > lastCompleteDay) {
endDate = lastCompleteDay; endDate = lastCompleteDay;
warnings.push("end_date_clamped"); warnings.push("end_date_clamped");
} }
const ninetyDayFloor = shiftGa4Date(endDate, -89);
if (startDate < ninetyDayFloor) {
startDate = ninetyDayFloor;
warnings.push("start_date_clamped");
}
if (startDate > endDate) { if (startDate > endDate) {
throw new Ga4ReportError( throw new Ga4ReportError(
"validation_error", "validation_error",

View File

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

View File

@ -244,3 +244,15 @@ export async function getProjectForOrganization(
return mapProject(project); 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 { captureServerEvent } from "@/server/lib/posthog";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_SCOPE } from "@/lib/oauth-resource"; import { MCP_SCOPE } from "@/lib/oauth-resource";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import type { ToolAuthContext } from "@/server/mcp/context"; import type { ToolAuthContext } from "@/server/mcp/context";
// SAM's read-only view of the project's shared memory. The block has no `set` // 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 // confirmed against a second Autumn read path before refusing — a
// stale check reading here once locked a paying customer out of chat. // stale check reading here once locked a paying customer out of chat.
const { organizationId } = ctx.project; const { organizationId } = ctx.project;
if (await isHostedServerAuthMode()) { const hosted = await isHostedServerAuthMode();
if (hosted) {
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({ const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
userId: ctx.row.userId, userId: ctx.row.userId,
userEmail: ctx.userEmail, userEmail: ctx.userEmail,
@ -285,10 +287,28 @@ export class SamChatAgent extends Think {
const baseUrl = const baseUrl =
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ?? (await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
"https://app.openseo.so"; "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 = { const authContext: ToolAuthContext = {
userId: ctx.row.userId, userId: ctx.row.userId,
userEmail: ctx.userEmail, userEmail: ctx.userEmail,
organizationId, 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, baseUrl,
clientId: null, clientId: null,
scopes: [MCP_SCOPE], scopes: [MCP_SCOPE],

View File

@ -6,7 +6,7 @@ import type { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transpor
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
verifyApiKey: vi.fn(), verifyApiKey: vi.fn(),
getHostedUser: vi.fn(), getHostedUser: vi.fn(),
getOrCreateDefaultHostedOrganization: vi.fn(), resolveExistingActiveHostedOrganization: vi.fn(),
recordMcpAuthorized: vi.fn(), recordMcpAuthorized: vi.fn(),
handleAuthenticatedOpenSeoMcpRequest: handleAuthenticatedOpenSeoMcpRequest:
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(), vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
@ -29,8 +29,8 @@ vi.mock("@/server/auth/repositories/AuthRepository", () => ({
})); }));
vi.mock("@/server/auth/default-hosted-organization", () => ({ vi.mock("@/server/auth/default-hosted-organization", () => ({
getOrCreateDefaultHostedOrganization: resolveExistingActiveHostedOrganization:
mocks.getOrCreateDefaultHostedOrganization, mocks.resolveExistingActiveHostedOrganization,
})); }));
vi.mock("@/server/features/activation/mcpActivation", () => ({ vi.mock("@/server/features/activation/mcpActivation", () => ({
@ -63,7 +63,10 @@ describe("handleMcpApiKeyRequest", () => {
email: "person@example.com", email: "person@example.com",
name: "Person", name: "Person",
}); });
mocks.getOrCreateDefaultHostedOrganization.mockResolvedValue("org-1"); mocks.resolveExistingActiveHostedOrganization.mockResolvedValue({
organizationId: "org-1",
role: "owner",
});
mocks.recordMcpAuthorized.mockResolvedValue(undefined); mocks.recordMcpAuthorized.mockResolvedValue(undefined);
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue( mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
new Response("mcp response"), new Response("mcp response"),
@ -84,9 +87,8 @@ describe("handleMcpApiKeyRequest", () => {
expect(mocks.verifyApiKey).toHaveBeenCalledWith({ expect(mocks.verifyApiKey).toHaveBeenCalledWith({
body: { key: "oseo_secret" }, body: { key: "oseo_secret" },
}); });
expect(mocks.getOrCreateDefaultHostedOrganization).toHaveBeenCalledWith( expect(mocks.resolveExistingActiveHostedOrganization).toHaveBeenCalledWith(
"user-1", "user-1",
expect.any(Function),
); );
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1"); expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1); expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
@ -100,6 +102,10 @@ describe("handleMcpApiKeyRequest", () => {
userId: "user-1", userId: "user-1",
userEmail: "person@example.com", userEmail: "person@example.com",
organizationId: "org-1", 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], scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key", clientId: "api_key",
baseUrl: "https://app.openseo.so", 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 () => { it("returns 401 for an invalid key without invoking the transport", async () => {
mocks.verifyApiKey.mockResolvedValue({ mocks.verifyApiKey.mockResolvedValue({
valid: false, valid: false,

View File

@ -1,7 +1,7 @@
import { getAuth, getHostedBaseUrl } from "@/lib/auth"; import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { API_KEY_PREFIX } from "@/lib/auth-api-key"; import { API_KEY_PREFIX } from "@/lib/auth-api-key";
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource"; 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 { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation"; import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context"; import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
@ -29,6 +29,7 @@ function apiKeyErrorResponse(
const isLimited = const isLimited =
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED"; error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
const isForbidden = error?.code === "FORBIDDEN";
if (isLimited) { if (isLimited) {
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its // The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
// published error type omits `details`, so narrow at runtime. // published error type omits `details`, so narrow at runtime.
@ -49,13 +50,19 @@ function apiKeyErrorResponse(
? error?.code === "RATE_LIMITED" ? error?.code === "RATE_LIMITED"
? "rate_limited" ? "rate_limited"
: "usage_exceeded" : "usage_exceeded"
: isForbidden
? "account_access_revoked"
: "invalid_api_key"; : "invalid_api_key";
const description = isLimited const description = isLimited
? typeof error?.message === "string" ? typeof error?.message === "string"
? error.message ? error.message
: "API key request limit reached" : "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"; : "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 // 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. // 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 // 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 // best-effort, which is all this needs to be: credits bound spend, this
// bounds runaway request volume. // 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; const rateLimit = (env as { MCP_RATE_LIMIT?: RateLimit }).MCP_RATE_LIMIT;
if (rateLimit) { if (rateLimit) {
const { success } = await rateLimit.limit({ key: userId }); const { success } = await rateLimit.limit({ key: userId });
@ -119,22 +126,31 @@ export async function handleMcpApiKeyRequest(
const user = await AuthRepository.getHostedUser(userId); const user = await AuthRepository.getHostedUser(userId);
if (!user?.email) return apiKeyErrorResponse(null); if (!user?.email) return apiKeyErrorResponse(null);
// API keys bill the user's default hosted workspace (their first org). // API keys bill the user's active organization. Keys are user-scoped and
// The hosted product provisions exactly one org per user. Decided // the org derives from the project each tool call names (project-level
// direction for multi-org: keys stay user-scoped and the org derives from // authz) — not key→org binding. Fail closed if the user has no existing
// the project each tool call names (project-level authz) — not key→org // memberships; we never mint a default organization for an API key.
// binding. const resolved = await resolveExistingActiveHostedOrganization(userId);
const organizationId = await getOrCreateDefaultHostedOrganization( if (!resolved) {
userId, return apiKeyErrorResponse({
(body) => authApi.createOrganization({ body }), 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 // clientId "api_key" satisfies the hosted transport's fail-closed props
// schema and counts these calls as external MCP clients in telemetry. // 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({ const props = createWorkersOAuthMcpProps({
userId, userId,
userEmail: user.email, userEmail: user.email,
organizationId, organizationId,
role,
orgScope: "user",
baseUrl: getHostedBaseUrl(), baseUrl: getHostedBaseUrl(),
scopes: [...MCP_OAUTH_SCOPES], scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key", clientId: "api_key",

View File

@ -7,6 +7,18 @@ export type ToolAuthContext = {
userId: string; userId: string;
userEmail: string; userEmail: string;
organizationId: 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[]; scopes: string[];
clientId: string | null; clientId: string | null;
baseUrl: string; baseUrl: string;
@ -23,6 +35,12 @@ const applicationAuthContextSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
userEmail: z.string().min(1), userEmail: z.string().min(1),
organizationId: 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(), baseUrl: z.string().url(),
// Compatibility fallback until workers-oauth-provider supplies the verified // Compatibility fallback until workers-oauth-provider supplies the verified
// context marker consumed by Agents SDK 0.20.x (the // context marker consumed by Agents SDK 0.20.x (the
@ -75,10 +93,23 @@ export function createMcpToolContext(
const authInfo = context.http?.authInfo; const authInfo = context.http?.authInfo;
const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null; const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null;
const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? []; 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 { return {
auth: { auth: {
...applicationAuth, ...applicationAuth,
role,
orgScope,
clientId, clientId,
scopes, scopes,
}, },

View File

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

View File

@ -3,11 +3,20 @@ import { makeToolContext } from "@/server/mcp/tools/tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
getProjectWithOrganization: vi.fn(),
getMembership: vi.fn(),
})); }));
vi.mock("@/server/features/projects/services/ProjectService", () => ({ vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: { ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization, 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", userId: "user_123",
userEmail: "alice@example.com", userEmail: "alice@example.com",
organizationId: "org_123", organizationId: "org_123",
role: "owner",
orgScope: "pinned",
clientId: "client_123", clientId: "client_123",
scopes: ["mcp"], scopes: ["mcp"],
}, },
@ -105,3 +116,77 @@ describe("withMcpProjectAuth", () => {
expect(handler).not.toHaveBeenCalled(); 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 { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context"; import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
@ -12,6 +13,34 @@ async function requireProjectAccess(
) { ) {
const { baseUrl, ...auth } = toolContext.auth; 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. // Authorize the caller-supplied projectId against the token's organization.
// Assert on the result instead of relying on the lookup throwing, so this // 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. // 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(() => ({ const mocks = vi.hoisted(() => ({
createProject: vi.fn(), createProject: vi.fn(),
listMembershipsForUser: vi.fn(),
})); }));
vi.mock("@/server/features/projects/services/ProjectService", () => ({ 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(); const toolContext = makeToolContext();
describe("create_project MCP tool", () => { describe("create_project MCP tool", () => {
@ -94,4 +101,83 @@ describe("create_project MCP tool", () => {
); );
expect(mocks.createProject).not.toHaveBeenCalled(); 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 { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
@ -33,10 +35,68 @@ const inputSchema = {
.describe( .describe(
'Optional language code (e.g. "en", "fr"). Requires locationCode; derived from the location when omitted.', '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; } as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>; 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 = { export const createProjectTool = {
name: "create_project", name: "create_project",
config: { config: {
@ -65,10 +125,16 @@ export const createProjectTool = {
}, },
handler: async (args: Args, context: ToolContext) => { handler: async (args: Args, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth; 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 // Reuse the app's create schema so the market pair rule (a languageCode
// requires a locationCode) and domain normalization match the dashboard. // requires a locationCode) and domain normalization match the dashboard.
// A rejection is bad caller input, not a fault: VALIDATION_ERROR keeps it // 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); const parsedInput = createProjectSchema.safeParse(args);
if (!parsedInput.success) { if (!parsedInput.success) {
throw new AppError( throw new AppError(
@ -78,7 +144,7 @@ export const createProjectTool = {
} }
const input = parsedInput.data; const input = parsedInput.data;
const project = await ProjectService.createProject( const project = await ProjectService.createProject(
auth.organizationId, target.organizationId,
input, input,
); );
return mcpResponse({ return mcpResponse({

View File

@ -253,6 +253,7 @@ describe("Google Analytics MCP tools", () => {
}, },
comparison: {}, comparison: {},
trend: [], trend: [],
warnings: [],
}); });
mocks.getMeasurementHealth.mockResolvedValue({ mocks.getMeasurementHealth.mockResolvedValue({
status: "ok", status: "ok",

View File

@ -2,6 +2,7 @@
import type { CallToolResult } from "@modelcontextprotocol/server"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { z } from "zod"; import { z } from "zod";
import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService"; import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService";
import { OVERVIEW_METRICS } from "@/server/features/ga4/services/Ga4ReportDefinitions";
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService"; import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
import { import {
GscApiError, GscApiError,
@ -11,6 +12,7 @@ import {
import { import {
Ga4ReportingService, Ga4ReportingService,
type Ga4ReportInput, type Ga4ReportInput,
type Ga4ReportResult,
} from "@/server/features/ga4/services/Ga4ReportingService"; } from "@/server/features/ga4/services/Ga4ReportingService";
import { Ga4ReportError } from "@/server/lib/ga4Errors"; import { Ga4ReportError } from "@/server/lib/ga4Errors";
import { SearchOpportunityService } from "@/server/features/ga4/services/SearchOpportunityService"; import { SearchOpportunityService } from "@/server/features/ga4/services/SearchOpportunityService";
@ -19,6 +21,7 @@ import { mcpResponse } from "@/server/mcp/formatters";
import { looseObjectOutputSchema } from "@/server/mcp/output-schemas"; import { looseObjectOutputSchema } from "@/server/mcp/output-schemas";
import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas"; import { projectIdSchema } from "@/server/mcp/schemas";
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
const dateSchema = z const dateSchema = z
@ -215,10 +218,55 @@ function errorResponse(
}); });
} }
function reportText( type Ga4ReportRow = Ga4ReportResult["rows"][number];
label: string, type Ga4OverviewResult = Awaited<
result: Awaited<ReturnType<typeof Ga4ReportingService.runReport>>, ReturnType<typeof Ga4OrganicOverviewService.getOrganicOverview>
>;
// These four MCP tools accept channel=all. landing_pages is Organic Search
// only and has no channel argument.
const CHANNEL_SELECTABLE_REPORTS = new Set<
Ga4ReportResult["request"]["reportKind"]
>([
"page_performance",
"key_events",
"ecommerce_performance",
"audience_breakdown",
]);
function reportTableColumns(
result: Ga4ReportResult,
): McpTableColumn<Ga4ReportRow>[] {
return [...result.request.dimensions, ...result.request.metrics].map(
(key) => ({
header: key,
value: (row) => row[key],
}),
);
}
function emptyOrganicHint(result: Ga4ReportResult): string {
if (
result.totalRowCount !== 0 ||
result.request.channel !== "organic_search"
) { ) {
return "";
}
return CHANNEL_SELECTABLE_REPORTS.has(result.request.reportKind)
? " This report is filtered to Organic Search. Pass channel=all to include every channel."
: " This report is limited to Organic Search.";
}
function endDateClampNote(result: {
warnings: string[];
request: { resolvedDateRange: { endDate: string } };
}) {
return result.warnings.includes("end_date_clamped")
? ` The requested endDate was moved back to ${result.request.resolvedDateRange.endDate}, the last complete Analytics day.`
: "";
}
function reportText(label: string, result: Ga4ReportResult) {
const range = result.request.resolvedDateRange; const range = result.request.resolvedDateRange;
const comparison = result.comparison const comparison = result.comparison
? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).` ? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).`
@ -227,7 +275,37 @@ function reportText(
result.diagnostics.length > 0 result.diagnostics.length > 0
? ` ${result.diagnostics.length} diagnostic finding(s) are included.` ? ` ${result.diagnostics.length} diagnostic finding(s) are included.`
: ""; : "";
return `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${comparison}${diagnostics}${result.reportMetadata.hasLimitedData ? " Google marked this report as limited; inspect reportMetadata." : ""}`; const limited = result.reportMetadata.hasLimitedData
? " Google marked this report as limited; inspect reportMetadata."
: "";
const paginate = result.pageInfo.hasMore
? " More rows are available; call again with offset to page through them."
: "";
const summary = `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${endDateClampNote(result)}${comparison}${diagnostics}${limited}${emptyOrganicHint(result)}${paginate}`;
if (result.rows.length === 0) return summary;
return `${summary}\n${formatMcpTable(result.rows, reportTableColumns(result))}`;
}
function overviewText(result: Ga4OverviewResult) {
const range = result.request.resolvedDateRange;
const previousRange = result.request.previousDateRange;
const trendTruncated = result.warnings.includes("trend_truncated")
? ` The trend was cut at ${result.trend.length} rows; use trend=weekly or a shorter date range for the full series.`
: "";
const summary = `Organic overview for ${range.startDate} through ${range.endDate}, compared with ${previousRange.startDate} through ${previousRange.endDate}.${endDateClampNote(result)}${trendTruncated}`;
if (!result.current) {
return `${summary} No Organic Search rows for this date range.`;
}
const rows = OVERVIEW_METRICS.map((metric) => ({
metric,
current: result.current[metric] ?? null,
previous: result.previous?.[metric] ?? null,
}));
return `${summary}\n${formatMcpTable(rows, [
{ header: "metric", value: (row) => row.metric },
{ header: "current", value: (row) => row.current },
{ header: "previous", value: (row) => row.previous },
])}`;
} }
function createAnalyticsReportHandler<TArgs extends ProjectArgs>( function createAnalyticsReportHandler<TArgs extends ProjectArgs>(
@ -407,7 +485,7 @@ export const getGoogleAnalyticsOrganicOverviewTool = {
try { try {
const result = await Ga4OrganicOverviewService.getOrganicOverview(args); const result = await Ga4OrganicOverviewService.getOrganicOverview(args);
return mcpResponse({ return mcpResponse({
text: `Organic overview for ${result.request.resolvedDateRange.startDate} through ${result.request.resolvedDateRange.endDate}, compared with ${result.request.previousDateRange.startDate} through ${result.request.previousDateRange.endDate}.`, text: overviewText(result),
meta: buildProjectMeta(context, args.projectId), meta: buildProjectMeta(context, args.projectId),
structuredContent: result, structuredContent: result,
}); });

View File

@ -1,3 +1,4 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { type ToolContext } from "@/server/mcp/context"; 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 { buildDashboardUrl } from "@/server/mcp/urls";
import { z } from "zod"; 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 = { export const listProjectsTool = {
name: "list_projects", name: "list_projects",
config: { config: {
title: "List projects", title: "List projects",
description: 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>, inputSchema: {} as Record<string, never>,
outputSchema: { outputSchema: {
projects: z.array( projects: z.array(
@ -22,6 +53,8 @@ export const listProjectsTool = {
locationCode: z.number(), locationCode: z.number(),
languageCode: z.string(), languageCode: z.string(),
url: z.string(), url: z.string(),
organization: z.string().optional(),
organizationId: z.string().optional(),
}) })
.passthrough(), .passthrough(),
), ),
@ -35,13 +68,13 @@ export const listProjectsTool = {
}, },
handler: async (_args: Record<string, never>, context: ToolContext) => { handler: async (_args: Record<string, never>, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth; const { baseUrl, ...auth } = context.auth;
const projects = await ProjectService.listProjects(auth.organizationId); const projects = await listVisibleProjects(auth);
const lines = const lines =
projects.length === 0 projects.length === 0
? ["No projects yet. Create one in the dashboard."] ? ["No projects yet. Create one in the dashboard."]
: projects.map( : projects.map(
(p) => (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({ return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`, text: `Projects (${projects.length}):\n${lines.join("\n")}`,
@ -56,6 +89,8 @@ export const listProjectsTool = {
locationCode: p.locationCode, locationCode: p.locationCode,
languageCode: p.languageCode, languageCode: p.languageCode,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`), 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", () => ({ vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
KeywordResearchService: { KeywordResearchService: {
getSavedKeywords: mocks.getSavedKeywords, getSavedKeywords: mocks.getSavedKeywords,

View File

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

View File

@ -1,13 +1,21 @@
/* eslint-disable max-lines, max-lines-per-function -- one spec covers every service-backed MCP text table */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as researchTools from "./dataforseo-research-tools"; import * as researchTools from "./dataforseo-research-tools";
import { getBacklinksOverviewTool } from "./get-backlinks-overview"; import { getBacklinksOverviewTool } from "./get-backlinks-overview";
import { getBacklinksProfileTool } from "./get-backlinks-profile"; import { getBacklinksProfileTool } from "./get-backlinks-profile";
import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions"; import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions";
import {
getGoogleAnalyticsOrganicLandingPagesTool,
getGoogleAnalyticsOrganicOverviewTool,
getGoogleAnalyticsPagePerformanceTool,
getGoogleAnalyticsTrafficAcquisitionTool,
} from "./google-analytics-tools";
import { getRankTrackerTool } from "./get-rank-tracker"; import { getRankTrackerTool } from "./get-rank-tracker";
import { getBusinessUpdatesTool } from "./local-seo-tools"; import { getBusinessUpdatesTool } from "./local-seo-tools";
import { getSerpResultsTool } from "./get-serp-results"; import { getSerpResultsTool } from "./get-serp-results";
import { researchKeywordsTool } from "./research-keywords"; import { researchKeywordsTool } from "./research-keywords";
import { makeToolContext, textContent } from "./tool-test-support"; import { makeToolContext, textContent } from "./tool-test-support";
import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures";
import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTarget"; import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTarget";
// Verifies that each tool renders its actual row data into the text content // Verifies that each tool renders its actual row data into the text content
@ -29,6 +37,8 @@ const mocks = vi.hoisted(() => ({
getLatestResults: vi.fn(), getLatestResults: vi.fn(),
getTracker: vi.fn(), getTracker: vi.fn(),
getConfigs: vi.fn(), getConfigs: vi.fn(),
runGa4Report: vi.fn(),
getOrganicOverview: vi.fn(),
})); }));
vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("cloudflare:workers", () => ({ env: {} }));
@ -81,6 +91,14 @@ vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({
getConfigs: mocks.getConfigs, getConfigs: mocks.getConfigs,
}, },
})); }));
vi.mock("@/server/features/ga4/services/Ga4ReportingService", () => ({
Ga4ReportingService: { runReport: mocks.runGa4Report },
}));
vi.mock("@/server/features/ga4/services/Ga4OrganicOverviewService", () => ({
Ga4OrganicOverviewService: {
getOrganicOverview: mocks.getOrganicOverview,
},
}));
const toolContext = makeToolContext(); const toolContext = makeToolContext();
@ -415,4 +433,239 @@ describe("MCP tool text output (service-backed tools)", () => {
// Rows are trimmed to the depth that was crawled, not the fixed top 20. // Rows are trimmed to the depth that was crawled, not the fixed top 20.
expect(textContent(result)).toContain('"seo tools" (30 results)'); expect(textContent(result)).toContain('"seo tools" (30 results)');
}); });
it("get_google_analytics_organic_landing_pages renders report rows in the text table", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({
rowCount: 2,
totalRowCount: 2,
rows: [
{
hostName: "example.com",
landingPage: "/home",
sessions: 12,
activeUsers: 9,
},
{
hostName: "example.com",
landingPage: "/blog",
sessions: 4,
activeUsers: 3,
},
],
request: {
dimensions: ["hostName", "landingPage"],
metrics: ["sessions", "activeUsers"],
},
}),
);
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 },
toolContext,
);
expect(textContent(result)).toEqual(
[
"Organic landing pages: 2 of 2 rows for 2026-07-09 through 2026-08-05.",
"hostName | landingPage | sessions | activeUsers",
"example.com | /home | 12 | 9",
"example.com | /blog | 4 | 3",
].join("\n"),
);
});
it("get_google_analytics_organic_landing_pages renders every fetched row and points at offset paging", async () => {
const rows = Array.from({ length: 16 }, (_, index) => ({
hostName: "example.com",
landingPage: `/p/${index + 1}`,
sessions: 16 - index,
}));
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({
rowCount: 16,
totalRowCount: 40,
rows,
pageInfo: { offset: 0, limit: 16, hasMore: true, nextOffset: 16 },
request: {
dimensions: ["hostName", "landingPage"],
metrics: ["sessions"],
},
}),
);
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 16, offset: 0 },
toolContext,
);
expect(textContent(result)).toEqual(
[
"Organic landing pages: 16 of 40 rows for 2026-07-09 through 2026-08-05. More rows are available; call again with offset to page through them.",
"hostName | landingPage | sessions",
...rows.map(
(row) => `${row.hostName} | ${row.landingPage} | ${row.sessions}`,
),
].join("\n"),
);
expect(result.structuredContent).toMatchObject({ rows });
});
it("get_google_analytics_page_performance names the Organic Search filter when empty", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({
request: {
reportKind: "page_performance",
channel: "organic_search",
dimensions: ["hostName", "pagePath"],
metrics: ["screenPageViews"],
},
}),
);
const result = await getGoogleAnalyticsPagePerformanceTool.handler(
{
projectId: "project_1",
includeDate: false,
channel: "organic_search",
limit: 100,
offset: 0,
},
toolContext,
);
expect(textContent(result)).toEqual(
"Page performance: 0 of 0 rows for 2026-07-09 through 2026-08-05. This report is filtered to Organic Search. Pass channel=all to include every channel.",
);
});
it("get_google_analytics_organic_landing_pages names Organic Search without a channel argument", async () => {
mocks.runGa4Report.mockResolvedValue(makeGa4ReportResult());
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 },
toolContext,
);
expect(textContent(result)).toEqual(
"Organic landing pages: 0 of 0 rows for 2026-07-09 through 2026-08-05. This report is limited to Organic Search.",
);
});
it("get_google_analytics_organic_landing_pages states an end-date clamp", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({ warnings: ["end_date_clamped"] }),
);
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 },
toolContext,
);
expect(textContent(result)).toEqual(
"Organic landing pages: 0 of 0 rows for 2026-07-09 through 2026-08-05. The requested endDate was moved back to 2026-08-05, the last complete Analytics day. This report is limited to Organic Search.",
);
});
it("get_google_analytics_traffic_acquisition does not mention Organic Search when empty", async () => {
mocks.runGa4Report.mockResolvedValue(
makeGa4ReportResult({
request: {
reportKind: "traffic_acquisition",
channel: "all",
dimensions: ["sessionDefaultChannelGroup"],
metrics: ["sessions"],
},
}),
);
const result = await getGoogleAnalyticsTrafficAcquisitionTool.handler(
{
projectId: "project_1",
breakdown: "channel_group",
comparePreviousPeriod: false,
limit: 100,
offset: 0,
},
toolContext,
);
expect(textContent(result)).toEqual(
"Traffic acquisition: 0 of 0 rows for 2026-07-09 through 2026-08-05.",
);
});
it("get_google_analytics_organic_overview renders current and previous totals", async () => {
mocks.getOrganicOverview.mockResolvedValue({
status: "ok",
request: {
resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" },
},
warnings: [],
current: {
sessions: 120,
activeUsers: 80,
engagedSessions: 70,
engagementRate: 0.58,
keyEvents: 9,
transactions: 2,
purchaseRevenue: 40.5,
},
previous: {
sessions: 100,
activeUsers: 70,
engagedSessions: 60,
engagementRate: 0.5,
keyEvents: 8,
transactions: 1,
purchaseRevenue: 20,
},
comparison: {},
trend: [{ date: "20260709", sessions: 5 }],
});
const result = await getGoogleAnalyticsOrganicOverviewTool.handler(
{ projectId: "project_1", trend: "daily" },
toolContext,
);
expect(textContent(result)).toEqual(
[
"Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08.",
"metric | current | previous",
"sessions | 120 | 100",
"activeUsers | 80 | 70",
"engagedSessions | 70 | 60",
"engagementRate | 0.58 | 0.50",
"keyEvents | 9 | 8",
"transactions | 2 | 1",
"purchaseRevenue | 40.50 | 20",
].join("\n"),
);
});
it("get_google_analytics_organic_overview states a truncated trend and names Organic Search when there is no current row", async () => {
mocks.getOrganicOverview.mockResolvedValue({
status: "ok",
request: {
resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" },
previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" },
},
warnings: ["trend_truncated"],
current: null,
previous: null,
comparison: {},
trend: [],
});
const result = await getGoogleAnalyticsOrganicOverviewTool.handler(
{ projectId: "project_1", trend: "daily" },
toolContext,
);
expect(textContent(result)).toEqual(
"Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08. The trend was cut at 0 rows; use trend=weekly or a shorter date range for the full series. No Organic Search rows for this date range.",
);
});
}); });

View File

@ -9,6 +9,14 @@ vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://open-seo.test", 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", () => ({ vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext: vi.fn(), resolveCloudflareAccessContext: vi.fn(),
})); }));

View File

@ -17,6 +17,14 @@ const selfHostedAuthMocks = vi.hoisted(() => ({
createMcpHandler: vi.fn(), createMcpHandler: vi.fn(),
})); }));
const authRepositoryMocks = vi.hoisted(() => ({
getMembership: vi.fn(),
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: authRepositoryMocks,
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext: resolveCloudflareAccessContext:
selfHostedAuthMocks.resolveCloudflareAccessContext, selfHostedAuthMocks.resolveCloudflareAccessContext,
@ -201,6 +209,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
}); });
describe("handleAuthenticatedOpenSeoMcpRequest", () => { describe("handleAuthenticatedOpenSeoMcpRequest", () => {
beforeEach(() => {
authRepositoryMocks.getMembership.mockResolvedValue({ role: "owner" });
});
it("accepts the provider's encrypted identity and MCP scope fallback", async () => { it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
const props = hostedProps(); const props = hostedProps();
@ -223,9 +235,14 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
legacy: "reject", legacy: "reject",
}), }),
); );
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith( // The transport stamps the per-request role into the props it hands the
props, // 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 () => { it("routes modern-era requests to the SDK handler", async () => {
@ -288,9 +305,12 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
); );
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith( expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
props, [MCP_AUTH_CONTEXT_PROP]: {
); ...props[MCP_AUTH_CONTEXT_PROP],
role: "owner",
},
});
}); });
it.each([ it.each([
@ -335,6 +355,29 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
expect(response.status).toBe(403); 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 () => { it("rejects an OAuth client without the MCP scope", async () => {
const props = hostedProps(["offline_access"]); const props = hostedProps(["offline_access"]);

View File

@ -20,6 +20,7 @@ import {
} from "@/server/mcp/context"; } from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { createOpenSeoMcpServer } from "@/server/mcp/server"; 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 // Mirrors the agents SDK's DEFAULT_CORS_OPTIONS so legacy responses carry the
// same CORS surface as the modern handler's. // 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 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, hostedUrl.hostname,
SURFMIND_CHROME_EXTENSION_HOSTNAME, SURFMIND_CHROME_EXTENSION_HOSTNAME,
])(request, env, ctx); ])(request, env, ctx);

View File

@ -1,5 +1,6 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { AuditService } from "@/server/features/audit/services/AuditService"; import { AuditService } from "@/server/features/audit/services/AuditService";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
@ -77,6 +78,9 @@ export const deleteAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(deleteAuditSchema) .validator(deleteAuditSchema)
.handler(async ({ data, context }) => { .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); await AuditService.remove(data.auditId, context.projectId);
return { success: true }; return { success: true };
}); });

View File

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

View File

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

View File

@ -10,6 +10,7 @@ const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
userEmail: z.string(), userEmail: z.string(),
emailVerified: z.boolean(), emailVerified: z.boolean(),
organizationId: z.string(), organizationId: z.string(),
role: z.string(),
project: z.any().optional(), 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 { createServerFn } from "@tanstack/react-start";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { import {
requireAuthenticatedContext, requireAuthenticatedContext,
@ -25,9 +26,10 @@ export const getProjects = createServerFn({ method: "POST" })
export const createProject = createServerFn({ method: "POST" }) export const createProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.validator(createProjectSchema) .validator(createProjectSchema)
.handler(async ({ data, context }) => .handler(async ({ data, context }) => {
ProjectService.createProject(context.organizationId, data), requireOrgPermission(context, { project: ["create"] });
); return ProjectService.createProject(context.organizationId, data);
});
export const updateProject = createServerFn({ method: "POST" }) export const updateProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
@ -53,9 +55,10 @@ export const setProjectMarket = createServerFn({ method: "POST" })
export const archiveProject = createServerFn({ method: "POST" }) export const archiveProject = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
.validator(archiveProjectSchema) .validator(archiveProjectSchema)
.handler(async ({ data, context }) => .handler(async ({ data, context }) => {
ProjectService.archiveProject(context.organizationId, data), requireOrgPermission(context, { project: ["delete"] });
); return ProjectService.archiveProject(context.organizationId, data);
});
export const getArchivedProjects = createServerFn({ method: "POST" }) export const getArchivedProjects = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
@ -66,9 +69,10 @@ export const getArchivedProjects = createServerFn({ method: "POST" })
export const restoreProject = createServerFn({ method: "POST" }) export const restoreProject = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)
.validator(restoreProjectSchema) .validator(restoreProjectSchema)
.handler(async ({ data, context }) => .handler(async ({ data, context }) => {
ProjectService.restoreProject(context.organizationId, data), requireOrgPermission(context, { project: ["delete"] });
); return ProjectService.restoreProject(context.organizationId, data);
});
export const getProjectAccess = createServerFn({ method: "POST" }) export const getProjectAccess = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext) .middleware(requireAuthenticatedContext)

View File

@ -16,6 +16,16 @@ export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
export const SEO_DATA_COST_MARKUP = 1.28; export const SEO_DATA_COST_MARKUP = 1.28;
export const LOW_CREDITS_THRESHOLD_USD = 0.25; export const LOW_CREDITS_THRESHOLD_USD = 0.25;
// Passed through to Stripe's checkout.sessions.create so checkout collects the
// legal business name, tax ID (EU VAT etc.), and full billing address — makes
// invoices valid for business customers. Display only, no Stripe Tax. Stripe
// requires customer_update.name "auto" to collect tax IDs for an existing customer.
export const AUTUMN_CHECKOUT_SESSION_PARAMS = {
tax_id_collection: { enabled: true },
billing_address_collection: "required",
customer_update: { name: "auto", address: "auto" },
} as const;
export function roundUsdForBilling(value: number) { export function roundUsdForBilling(value: number) {
return Math.round(value * 100000) / 100000; return Math.round(value * 100000) / 100000;
} }

View File

@ -1,5 +1,5 @@
import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig, loadEnv } from "vite"; import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths"; import tsConfigPaths from "vite-tsconfig-paths";
import viteReact from "@vitejs/plugin-react"; import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
@ -7,19 +7,14 @@ import { cloudflare } from "@cloudflare/vite-plugin";
import { devtools } from "@tanstack/devtools-vite"; import { devtools } from "@tanstack/devtools-vite";
import { leanWorkerBundle } from "./vite-plugin-lean-worker-bundle"; import { leanWorkerBundle } from "./vite-plugin-lean-worker-bundle";
export default defineConfig(({ mode }) => { export default defineConfig(() => {
const env = loadEnv(mode, process.cwd(), ""); const port = 3001;
const port = process.env.PORT
? Number(process.env.PORT) const showDevtools = false;
: env.PORT
? Number(env.PORT)
: 3001;
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
const allowedHosts = [ const allowedHosts = [
env.ALLOWED_HOST, "seo.thedomainnest.com",
env.BETTER_AUTH_URL ? new URL(env.BETTER_AUTH_URL).hostname : undefined, ];
].filter((host): host is string => Boolean(host));
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
return { return {
envPrefix: [ envPrefix: [
@ -30,20 +25,27 @@ export default defineConfig(({ mode }) => {
"POSTHOG_HOST", "POSTHOG_HOST",
"TURNSTILE_SITE_KEY", "TURNSTILE_SITE_KEY",
], ],
server: { server: {
allowedHosts, host: "0.0.0.0",
port, port,
allowedHosts,
}, },
preview: { preview: {
allowedHosts, host: "0.0.0.0",
port, port,
allowedHosts,
}, },
build: { build: {
sourcemap: emitSourcemaps, sourcemap: false,
outDir: emitSourcemaps ? "dist-sourcemaps" : "dist", outDir: "dist",
}, },
plugins: [ plugins: [
leanWorkerBundle(), leanWorkerBundle(),
showDevtools showDevtools
? devtools({ ? devtools({
consolePiping: { consolePiping: {
@ -52,18 +54,23 @@ export default defineConfig(({ mode }) => {
}, },
}) })
: null, : null,
cloudflare({ cloudflare({
inspectorPort: false, inspectorPort: false,
viteEnvironment: { name: "ssr" }, viteEnvironment: { name: "ssr" },
// The site-audit aux worker builds to dist/open_seo_audit/ and runs auxiliaryWorkers: [
// beside the main worker in dev and preview, with the app's {
// cross-script SITE_AUDIT_WORKFLOW / AUDIT_SCRATCHPAD bindings configPath: "./wrangler.audit.jsonc",
// resolved against it. },
auxiliaryWorkers: [{ configPath: "./wrangler.audit.jsonc" }], ],
}), }),
tsConfigPaths(), tsConfigPaths(),
tanstackStart(), tanstackStart(),
viteReact(), viteReact(),
tailwindcss(), tailwindcss(),
], ],
}; };

70
vite.config.ts.backup Normal file
View File

@ -0,0 +1,70 @@
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig, loadEnv } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { devtools } from "@tanstack/devtools-vite";
import { leanWorkerBundle } from "./vite-plugin-lean-worker-bundle";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const port = process.env.PORT
? Number(process.env.PORT)
: env.PORT
? Number(env.PORT)
: 3001;
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
const allowedHosts = [
env.ALLOWED_HOST,
env.BETTER_AUTH_URL ? new URL(env.BETTER_AUTH_URL).hostname : undefined,
].filter((host): host is string => Boolean(host));
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
return {
envPrefix: [
"VITE_",
"AUTH_MODE",
"BYPASS_EMAIL_VERIFICATION",
"POSTHOG_PUBLIC_KEY",
"POSTHOG_HOST",
"TURNSTILE_SITE_KEY",
],
server: {
allowedHosts,
port,
},
preview: {
allowedHosts,
port,
},
build: {
sourcemap: emitSourcemaps,
outDir: emitSourcemaps ? "dist-sourcemaps" : "dist",
},
plugins: [
leanWorkerBundle(),
showDevtools
? devtools({
consolePiping: {
enabled: true,
levels: ["log", "warn", "error", "info", "debug"],
},
})
: null,
cloudflare({
inspectorPort: false,
viteEnvironment: { name: "ssr" },
// The site-audit aux worker builds to dist/open_seo_audit/ and runs
// beside the main worker in dev and preview, with the app's
// cross-script SITE_AUDIT_WORKFLOW / AUDIT_SCRATCHPAD bindings
// resolved against it.
auxiliaryWorkers: [{ configPath: "./wrangler.audit.jsonc" }],
}),
tsConfigPaths(),
tanstackStart(),
viteReact(),
tailwindcss(),
],
};
});

View File

@ -0,0 +1,124 @@
---
title: "Two Surfaces, Two Timelines"
description: "Getting recommended by an LLM is two separate problems on different clocks. One of them you cannot move this quarter. The other is a finite list of pages you can edit this week."
author: "Jeremy Rivera"
date: "2026-09-04"
---
Last August I published six markdown companion files so AI crawlers would have something cheap and clean to read on my podcast site. Then I ran a control test on my own server and found that unscriptedseo.com was returning 429 to GPTBot on every request, from the same IP that was serving a browser 200 eight times in a row.
I had spent a week optimising content for a door that was locked.
That is the argument of this post. Getting recommended by an LLM is two problems, they run on different clocks, and most of what gets sold as a solution addresses the half you cannot move.
If you want an agent to run the audits below, connect the [OpenSEO MCP](/docs/mcp) first so it can pull your live ranking and Search Console data.
## Table of Contents
## The two surfaces
An assistant names your product from one of two places.
The first is what the model already knows. That is where "everyone knows Ahrefs, Semrush, SE Ranking" lives. It is fixed at training time and updates on the model maker's schedule.
The second is what the assistant fetches while it answers you. That is a live read of the open web, and you can work on it today.
![A two panel diagram titled Two Surfaces, Two Timelines. The left panel, Training data, is greyed out and labelled years, no lever this quarter, describing where established tool names live. The right panel, Retrieved pages, is highlighted and labelled this month, entirely workable, describing a live index of roughly forty pages that decide any product category.](/blog/two-surfaces-two-timelines/two-surfaces-two-timelines.png)
OpenAI documents the second surface plainly, and the wording repays a close read:
> ChatGPT search typically rewrites your query into one or more targeted queries that it sends those providers.
Two things follow. Your page is not being matched against what the user typed, it is being matched against a rewrite you never see. And OpenAI states directly that ["placement is not guaranteed"](https://help.openai.com/en/articles/9237897-chatgpt-search), along with the precondition: to make a website eligible for inclusion, allow OAI-Searchbot to crawl it.
Google describes its own version, a [query fan-out technique](https://developers.google.com/search/docs/appearance/ai-features) that issues "multiple related searches across subtopics and data sources," and says there are no additional requirements to appear in AI Overviews or AI Mode.
Perplexity separates the two surfaces explicitly in its [bot documentation](https://docs.perplexity.ai/guides/bots). PerplexityBot exists "to surface and link websites in search results" and is "not used to crawl content for AI foundation models," while Perplexity-User fetches a page live during an answer.
The retrieval half comes down to a crawl, a rewrite, and a selection. You can be excluded at any of the three.
## The overlap nobody agrees on
The obvious next question is whether the pages that get cited are the pages that already rank. I went looking for a number and found three that disagree.
![A bar chart titled The Studies Disagree, showing the share of AI Overview citations that also rank organically: Ahrefs 38 percent from 863,000 SERPs, Surfer SEO 52 percent from 405,576 AI Overviews, seoClarity 56 percent from 362,000 queries. Below, a second comparison shows the same Ahrefs measure at 76 percent in July 2025 falling to 38 percent in March 2026.](/blog/two-surfaces-two-timelines/ai-overview-organic-overlap-studies.png)
[Ahrefs](https://ahrefs.com/blog/ai-overview-citations-top-10) put it at 38%, from 863,000 SERPs and 4 million citation URLs. [Surfer](https://surferseo.com/blog/ai-overviews-study/) measured 52% across 405,576 AI Overviews. [seoClarity](https://www.seoclarity.net/research/aio-rankings-overlap) reports 56% from the top 20, across 362,000 queries and 5.1 million citations.
I do not think any of them is wrong. They cut at different top-N thresholds on different days. The seoClarity study also reports that 94% of queries showed *at least one* overlap, which is a much weaker claim than the 56% figure and gets quoted as though it were the same finding.
The number that matters is none of those three. Ahrefs measured roughly 76% in July 2025 and 38% in March 2026 using their own method both times, and they attribute the fall to query fan-out. The overlap is a moving trend rather than a constant, so anything you build on a single overlap figure has a shelf life of about a quarter.
Where citations concentrate is steadier and more useful. Research by Tom Wells of Peec AI, [published through Wix Studio's AI Search Lab](https://www.wix.com/studio/ai-search-lab/research/content-types-most-cited-by-llms), examined 1,056,727 citations across 75,000 answers and found listicles are the most-cited format at 21.9%, rising to 40% on commercial-intent queries. For "what tool should I use," the roundups are the corpus.
## The half you cannot rush
So what about the training side? This is where I expected a number and did not find one.
The mechanism is well studied. *Dated Data*, from a Johns Hopkins team, shows that a model's [effective knowledge cutoff differs from its reported one](https://arxiv.org/abs/2403.12958), because CommonCrawl dumps carry meaningful amounts of older data and deduplication is imperfect. The boundary is fuzzy. That work does not tell you how long a new brand takes to cross it.
As far as I can tell, nobody has published that figure. If you see a confident claim that it takes two years to enter the training data, ask where the number came from.
What does exist is a control condition inside a paper about something else. Hyunseok Paeng's ["Injection Paradox"](https://arxiv.org/pdf/2606.09204), accepted at the ICML 2026 FAGEN workshop, needed a product with low brand recognition and chose the Edifier NeoBuds Pro 3. The line I keep returning to sits in the methodology: the product received **0 out of 100 recommendations from both GPT-4o-mini and Haiku when no corpus was provided**. Given a retrieval corpus, the same product reaches a 54% baseline in Claude Opus.
That is a control result rather than the paper's headline, so I am careful about how much weight it carries. It is still the cleanest published demonstration I have found that for a product a model does not already know, retrieval is not an advantage on top of recognition. It is the whole route.
## The court drew the same line
The distinction has become load-bearing enough to turn up in the remedies opinion in *United States v. Google*. Judge Mehta ordered Google to make search index and user-interaction data available to qualified competitors, and in weighing publisher remedies the court considered letting publishers opt out of crawling "for inclusion in Google's search index and for training its GenAI models and products."
Index and training, named separately, as two things a publisher might refuse independently. Google's own patent for [generative summaries](https://patents.google.com/patent/US11769017B1/en) describes selecting result documents using "query-dependent measure(s), query-independent measure(s), and/or user-dependent measure(s)" and then linking back to the documents that verify the summary.
The industry is still arguing about whether these are one surface. The court and the patent office already treat them as two.
## Check the door first
Which brings me back to my own server, and to the check almost nobody runs.
![A two panel comparison titled Refused at the Door. Left panel, browser user agent, 200, eight requests back to back, every one served, X-Powered-By PHP header present. Right panel highlighted, GPTBot user agent, 429, four requests 45 seconds apart after a two minute cooldown, no X-Powered-By header. Caption notes six sibling sites on the same host served GPTBot 200 under the identical test.](/blog/two-surfaces-two-timelines/gptbot-user-agent-block-test.png)
Same IP. Browser user agent, eight requests back to back, 200 every time. GPTBot user agent, four requests spaced 45 seconds apart after a two minute cooldown, 429 every time. So this was not rate limiting.
The detail that settles it: the 429 carried no `X-Powered-By` header and the 200 did, so PHP never ran. The request was refused at the LiteSpeed edge before WordPress saw it. It was not in robots.txt, not a plugin, not `.htaccess`, and six sibling sites on the same host served GPTBot 200 under the identical test.
I had been publishing companion files for AI crawlers on a site that refused the crawler at the door. The static `.md` files still returned 200, because they never touch PHP, which was a useful accident rather than a plan.
You cannot be selected from a set you were never admitted to. This is the one part of the stack that is binary, cheap to test, and almost never tested.
## TL;DR
- Getting recommended is two problems. Training data is a multi-year brand problem. Retrieved pages are a finite list you can work this month.
- All three major vendors document the retrieval half. OpenAI says outright that placement is not guaranteed and that crawl access is the precondition.
- Citation-to-organic overlap is measured at 38%, 52% and 56% by three credible studies, and the same team's figure fell from 76% to 38% in eight months.
- Listicles are 21.9% of all citations and 40% on commercial queries.
- For a brand the model does not know, retrieval is the only route, not an edge.
- None of it matters if you are returning 429 to the crawler.
## Homework
1. **Test your own door.** Curl your site with each AI crawler's user agent and with a browser user agent, from the same IP, interleaved. Compare headers as well as status codes. `X-Powered-By` told me more than the 429 did.
2. **Build the retrieval corpus.** Pull the top twenty pages for the two or three questions a buyer actually asks, phrased the way a person asks them. "Best SEO tools for beginners" and "cheapest SEO tool" retrieve different pages.
3. **Mark where you already appear**, and split the list into present, absent, and present-but-wrong. Present-but-wrong, where your pricing or description is stale, is the fastest fix and nobody looks for it.
4. **Write the sentence that earns your slot** for every page you are absent from. If you cannot write one, that page is not a target.
### Full Prompt: Build the retrieval gap list
```
Using the OpenSEO MCP, for each of these buyer questions:
- <question 1>
- <question 2>
- <question 3>
1. Return the top 20 ranking URLs for each.
2. For each URL, fetch the page and report whether <my product> is mentioned,
and if so quote the exact sentence and note the stated price.
3. Flag any mention where the price or description is out of date.
4. Produce one table: URL, ranks for, mention status, stale claim, contact path.
5. Sort by ranking position, best first.
```
Optimising content while the crawler gets a 429 is an expensive way to feel productive.
---
*Sources are linked inline. First-party crawler data is from my own servers, August 2026. The panel referenced here was recorded 28 August 2026 for The Unscripted SEO Interview Podcast with [Patrick Stox](https://unscriptedseo.com/patrick-stox-on-building-in-the-geo-era/), Ben Senescu of OpenSEO, and [Ben Wills](https://unscriptedseo.com/ben-wills-one-word-prompt-llm-testing/) of OppAlerts. Every cited URL was verified on 2 September 2026.*

View File

@ -0,0 +1,105 @@
---
title: "What Broke the $99 Ceiling"
description: "A decade of indie SEO tools died against a price comparison they could not win. The thing that finally opened the market was not a cheaper tool, it was the collapse of the twenty hours a week it took to use one."
author: "Jeremy Rivera"
date: "2026-09-04"
---
Everybody has spent fifteen years complaining that SEO tools cost too much, so you would expect a $10 alternative to be the headline. The price was never what stopped you.
I spent years inside this problem. [Raven Tools](https://raventools.com/) brought me out of Homes.com and out to Tennessee, and back in 2008 to 2010, alongside [Moz](https://moz.com/), before [Ahrefs](https://ahrefs.com/) was a thought anybody had had, we were one of the better known SaaS tools in the space. I watched what happened to every indie tool that came after us, and it was always the same two questions.
Is that already in Semrush or Ahrefs? And do I need to pay for this *on top of* Semrush?
If you want an agent to run the audit at the end of this post, connect the [OpenSEO MCP](/docs/mcp) first so it can pull your live ranking and Search Console data.
## Table of Contents
## The ceiling nobody could price above
Those two questions built a ceiling at $99 a month, and that ceiling killed a decade of good software.
Price under $99 and you were a toy, useful but not something a team would build a process around. Price over $99 and you got compared to the full [Semrush](https://www.semrush.com/) suite on day one, and unless you had all of it, you lost. Every indie SEO tool had two options: play small forever, or jump to enterprise and skip the middle.
![A bar chart titled The $99 Ceiling comparing monthly entry prices: OpenSEO at $10 where most users never pass that tier, Ahrefs at $99 and Semrush at $110 as baselines, with a horizontal line marking the $99 comparison trigger above which any tool gets measured against a full suite.](/blog/what-broke-the-99-dollar-ceiling/price-ceiling-openseo.png)
That ceiling was real and it held for a decade. [Moz Pro has listed a $99 entry tier continuously since at least February 2016](https://moz.com/products/pro/pricing), which four separate archived snapshots confirm, and the tier above it drifted between $149 and $179 over the same period. The stability of the $99 line is the notable part.
One correction to the story I used to tell, though. The market converged *on* $99 rather than starting there. Ahrefs Lite was $79 a month in December 2015 and Semrush Pro was $69.95 in mid-2015. Both climbed to roughly $99 by 2017 and stopped. So this was a ceiling the market found, not one it was born with.
## What actually changed
When Ben Senescu told me OpenSEO runs at $10 a month, my first read was that somebody had finally undercut the ceiling. That is not what happened, and the real answer is more useful to you.
![A quote card reading: They aren't comparing against Semrush, because they've never used Semrush before. It was totally inaccessible to them at that price point. Attributed to Ben Senescu, founder of OpenSEO, from The Unscripted SEO Interview Podcast.](/blog/what-broke-the-99-dollar-ceiling/quote-never-used-semrush.png)
His paying customers are not professional SEOs. They are entrepreneurs doing SEO for the first time, people who never had the $110 option, so they are not comparing anything. The reason they can do it now is that the work got cheap.
Think about what an hour of SEO used to cost you in time. You wanted to change a meta title, so you downloaded a plugin, and you had to know [Yoast](https://yoast.com/) existed to know which plugin. Then you logged into WordPress, found the page, opened it, waited for it to load, made the change, saved it, and checked it. Ten minutes to edit one string. Everything else lived in spreadsheets, then a Google Doc, then another spreadsheet, then copy, paste, copy, paste.
That was the real bill: ten to twenty hours a week. Against twenty hours a week, the difference between a $10 tool and a $100 tool was a rounding error, which is why cheap tools never found a market. They solved the small half of the problem.
**Once the twenty hours collapses, the hundred dollars starts to matter.** That is why this market exists now and did not exist two years ago.
Worth being accurate about the pricing, since it is the whole argument: OpenSEO is $10 a month and free to start, and it is not free in the sense people usually mean. Good SEO data costs money everywhere, which is why every serious suite lands near the same number. You either bring your own DataForSEO key or pay a small fee on top of the data you use.
## Your constraint moved
If you sat out SEO because the tooling was priced for agencies, the door is open. The useful takeaway is not "go buy a cheap tool." It is that your constraint moved and you probably have not moved with it.
- **Execution used to be the bottleneck.** It is not any more. You can generate more work in an afternoon than you can evaluate in a week.
- **Judgement is the bottleneck now.** Which of these things is worth doing, and what do you leave out? No tool answers that.
- **The people who do well over the next two years** will be the ones who get good at saying no, rather than the ones who get good at prompting.
Ben put this better than I would have, and he was talking about his own roadmap when he said it:
![A quote card reading: I can do anything. What's the one thing I should do? Attributed to Ben Senescu, founder of OpenSEO, from The Unscripted SEO Interview Podcast.](/blog/what-broke-the-99-dollar-ceiling/quote-one-thing.png)
He had roughly sixty open pull requests from strangers, and by his own account most of them were probably good work. He declared bankruptcy on the queue, published a roadmap explaining why, and stopped taking external code. That is uncomfortable to write and it is also the job now.
## The cost nobody is pricing in
I do not want to hand you a clean story, because this swap has a bill attached.
When it takes ten minutes to edit a meta title, you think about whether the title is right. When it takes four seconds, you do not. You can produce a hundred pages that are each ninety-five percent correct and never find the five percent, because the errors do not cluster. They sit evenly across everything you made. Ben said the same about features, and it applies to content, redirects, schema, and most of the rest of this job.
The discipline that friction used to enforce now has to come from you, which is a harder ask than it sounds, and I do not think many people have noticed the swap.
## Do it with OpenSEO
Here is the audit I would actually run this month, and it takes about twenty minutes.
### 1. List what you would build if you had unlimited time
Write it all down. Twenty items, forty, however many. This is the part that used to be constrained and is not.
### 2. Mark which items a model can finish without you
Be honest. Most drafting, most technical cleanup, most first-pass keyword work.
### 3. Circle the ones that need your judgement
Positioning. Who you are for. What to leave out. Which client to say no to. That list is short, and it is the only list that matters now.
### Full Prompt: Find the work only you can do
```
Using the OpenSEO MCP, pull my current ranked keywords and top pages.
Then, for each item on this list of planned work:
- <paste your list>
1. Classify it as EXECUTABLE (a model can complete it end to end) or
JUDGEMENT (it needs a decision about positioning, audience, or tradeoffs).
2. For EXECUTABLE items, say what data you would need from OpenSEO to start.
3. For JUDGEMENT items, write the one question I have to answer first.
4. Return the JUDGEMENT list, shortest first.
```
Then go and do the judgement list yourself, carefully, and let the tooling take the rest.
The tooling got cheap. Your attention did not.
---
*Ben Senescu is the founder of OpenSEO. He joined me on The Unscripted SEO Interview Podcast on 13 August 2026; [the full conversation is here](https://unscriptedseo.com/ben-senescu-open-source-seo-99-ceiling/). Historical pricing was checked against archived vendor pages, and current pricing against each vendor's own pricing page, on 2 September 2026.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Some files were not shown because too many files have changed in this diff Show More