feat(gsc): support multiple Google accounts per user (#373)

This commit is contained in:
Ben Senescu 2026-07-09 21:49:08 -04:00 committed by GitHub
parent f2c41b1db1
commit dae0067233
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 7213 additions and 172 deletions

View File

@ -0,0 +1,13 @@
ALTER TABLE "gsc_connections" ADD COLUMN "gsc_account_id" text;
--> statement-breakpoint
UPDATE gsc_connections SET gsc_account_id = (
SELECT a.account_id FROM account a
WHERE a.user_id = gsc_connections.connected_by_user_id
AND a.provider_id = 'google-search-console'
)
WHERE gsc_account_id IS NULL
AND (
SELECT count(*) FROM account a2
WHERE a2.user_id = gsc_connections.connected_by_user_id
AND a2.provider_id = 'google-search-console'
) = 1;

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,13 @@
"when": 1783306049167, "when": 1783306049167,
"tag": "0007_same_marvel_zombies", "tag": "0007_same_marvel_zombies",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783630716543,
"tag": "0008_yummy_annihilus",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,13 @@
ALTER TABLE `gsc_connections` ADD `gsc_account_id` text;
--> statement-breakpoint
UPDATE gsc_connections SET gsc_account_id = (
SELECT a.account_id FROM account a
WHERE a.user_id = gsc_connections.connected_by_user_id
AND a.provider_id = 'google-search-console'
)
WHERE gsc_account_id IS NULL
AND (
SELECT count(*) FROM account a2
WHERE a2.user_id = gsc_connections.connected_by_user_id
AND a2.provider_id = 'google-search-console'
) = 1;

File diff suppressed because it is too large Load Diff

View File

@ -218,6 +218,13 @@
"when": 1783306047190, "when": 1783306047190,
"tag": "0030_legal_reaper", "tag": "0030_legal_reaper",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1783630714621,
"tag": "0031_furry_monster_badoon",
"breakpoints": true
} }
] ]
} }

View File

@ -6,7 +6,10 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning"; import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import { SitePicker } from "@/client/features/gsc/SitePicker"; import {
SitePicker,
type GscSiteSelection,
} from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink"; import { startGscLink } from "@/client/features/gsc/startGscLink";
import { import {
disconnectGsc, disconnectGsc,
@ -25,7 +28,9 @@ export function SearchConsoleConnectionCard({
const hosted = isHostedClientAuthMode(); const hosted = isHostedClientAuthMode();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [picking, setPicking] = React.useState(false); const [picking, setPicking] = React.useState(false);
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState<string>(""); const [selection, setSelection] = React.useState<GscSiteSelection | null>(
null,
);
const connectionKey = ["gscConnection", projectId]; const connectionKey = ["gscConnection", projectId];
const connectionQuery = useQuery({ const connectionQuery = useQuery({
@ -43,7 +48,13 @@ export function SearchConsoleConnectionCard({
queryFn: () => listGscSites({ data: { projectId } }), queryFn: () => listGscSites({ data: { projectId } }),
enabled: Boolean(showPicker && !selfHostedNeedsSetup), enabled: Boolean(showPicker && !selfHostedNeedsSetup),
}); });
const requiresReconnect = Boolean(sitesQuery.data?.requiresReconnect); const accounts = React.useMemo(
() => sitesQuery.data?.accounts ?? [],
[sitesQuery.data?.accounts],
);
const requiresReconnect = accounts.some(
(account) => account.requiresReconnect,
);
React.useEffect(() => { React.useEffect(() => {
if (!requiresReconnect) return; if (!requiresReconnect) return;
@ -54,9 +65,23 @@ export function SearchConsoleConnectionCard({
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY }); void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
}, [requiresReconnect, queryClient, projectId]); }, [requiresReconnect, queryClient, projectId]);
React.useEffect(() => {
if (selection) return;
for (const account of accounts) {
const selectedSite = account.sites.find((site) => site.isSelected);
if (selectedSite) {
setSelection({
accountId: account.accountId,
siteUrl: selectedSite.siteUrl,
});
return;
}
}
}, [accounts, selection]);
const setSiteMutation = useMutation({ const setSiteMutation = useMutation({
mutationFn: (siteUrl: string) => mutationFn: (selected: GscSiteSelection) =>
setGscSite({ data: { projectId, siteUrl } }), setGscSite({ data: { projectId, ...selected } }),
onSuccess: () => { onSuccess: () => {
captureClientEvent("gsc:property_select"); captureClientEvent("gsc:property_select");
toast.success("Search Console connected"); toast.success("Search Console connected");
@ -80,6 +105,7 @@ export function SearchConsoleConnectionCard({
onSuccess: () => { onSuccess: () => {
toast.success("Search Console disconnected"); toast.success("Search Console disconnected");
setPicking(false); setPicking(false);
setSelection(null);
void queryClient.invalidateQueries({ queryKey: connectionKey }); void queryClient.invalidateQueries({ queryKey: connectionKey });
// Disconnect can drop the account-level grant server-side; keep the // Disconnect can drop the account-level grant server-side; keep the
// shared grant-status cache (onboarding step + re-engagement nudge) honest. // shared grant-status cache (onboarding step + re-engagement nudge) honest.
@ -120,7 +146,7 @@ export function SearchConsoleConnectionCard({
siteUrl={connection?.siteUrl ?? ""} siteUrl={connection?.siteUrl ?? ""}
connectedByEmail={connection?.connectedByEmail ?? null} connectedByEmail={connection?.connectedByEmail ?? null}
onChange={() => { onChange={() => {
setSelectedSiteUrl(connection?.siteUrl ?? ""); setSelection(null);
setPicking(true); setPicking(true);
}} }}
onDisconnect={() => disconnectMutation.mutate()} onDisconnect={() => disconnectMutation.mutate()}
@ -129,14 +155,13 @@ export function SearchConsoleConnectionCard({
) : showPicker ? ( ) : showPicker ? (
<SitePicker <SitePicker
loading={sitesQuery.isLoading} loading={sitesQuery.isLoading}
error={sitesQuery.isError || requiresReconnect} error={sitesQuery.isError}
sites={sitesQuery.data?.sites ?? []} accounts={accounts}
selectedSiteUrl={selectedSiteUrl} selection={selection}
onSelect={setSelectedSiteUrl} onSelect={setSelection}
onSave={() => onSave={() => selection && setSiteMutation.mutate(selection)}
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
}
saving={setSiteMutation.isPending} saving={setSiteMutation.isPending}
onRetry={() => void sitesQuery.refetch()}
onReconnect={handleConnect} onReconnect={handleConnect}
secondaryAction={ secondaryAction={
connected connected

View File

@ -1,4 +1,5 @@
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { startGscLink } from "@/client/features/gsc/startGscLink";
type SiteOption = { type SiteOption = {
siteUrl: string; siteUrl: string;
@ -7,6 +8,18 @@ type SiteOption = {
isSelected: boolean; isSelected: boolean;
}; };
type AccountOption = {
accountId: string;
email: string | null;
requiresReconnect: boolean;
sites: SiteOption[];
};
export type GscSiteSelection = {
accountId: string;
siteUrl: string;
};
type SecondaryAction = { type SecondaryAction = {
label: string; label: string;
onClick: () => void; onClick: () => void;
@ -15,28 +28,30 @@ type SecondaryAction = {
}; };
/** /**
* Verified-property selector for a connected Google account. Shared by the * Verified-property selector for connected Google accounts. Shared by the
* Integrations card and the onboarding step. `secondaryAction` is optional * Integrations card and the onboarding step. `secondaryAction` is optional
* omit it where there's nothing to cancel/disconnect (e.g. onboarding). * omit it where there's nothing to cancel/disconnect (e.g. onboarding).
*/ */
export function SitePicker({ export function SitePicker({
loading, loading,
error, error,
sites, accounts,
selectedSiteUrl, selection,
onSelect, onSelect,
onSave, onSave,
saving, saving,
onRetry,
onReconnect, onReconnect,
secondaryAction, secondaryAction,
}: { }: {
loading: boolean; loading: boolean;
error: boolean; error: boolean;
sites: SiteOption[]; accounts: AccountOption[];
selectedSiteUrl: string; selection: GscSiteSelection | null;
onSelect: (siteUrl: string) => void; onSelect: (selection: GscSiteSelection) => void;
onSave: () => void; onSave: () => void;
saving: boolean; saving: boolean;
onRetry: () => void;
onReconnect: () => void; onReconnect: () => void;
secondaryAction?: SecondaryAction; secondaryAction?: SecondaryAction;
}) { }) {
@ -49,6 +64,26 @@ export function SitePicker({
); );
} }
if (error) { if (error) {
return (
<div className="space-y-3">
<p className="text-sm text-error">
Couldn't load your Search Console properties.
</p>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onRetry}
>
Try again
</button>
</div>
);
}
const allAccountsRequireReconnect =
accounts.length > 0 &&
accounts.every((account) => account.requiresReconnect);
if (allAccountsRequireReconnect) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-error"> <p className="text-sm text-error">
@ -65,6 +100,24 @@ export function SitePicker({
</div> </div>
); );
} }
const healthyAccounts = accounts.filter(
(account) => !account.requiresReconnect,
);
const options = healthyAccounts.flatMap((account) =>
account.sites.map((site) => ({
accountId: account.accountId,
siteUrl: site.siteUrl,
})),
);
const selectedIndex = selection
? options.findIndex(
(option) =>
option.accountId === selection.accountId &&
option.siteUrl === selection.siteUrl,
)
: -1;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<label className="block"> <label className="block">
@ -73,33 +126,61 @@ export function SitePicker({
</span> </span>
<select <select
className="select select-bordered w-full max-w-md" className="select select-bordered w-full max-w-md"
value={selectedSiteUrl} value={selectedIndex >= 0 ? String(selectedIndex) : ""}
onChange={(e) => onSelect(e.target.value)} onChange={(event) => {
const option = options[Number(event.target.value)];
if (option) onSelect(option);
}}
> >
<option value="" disabled> <option value="" disabled>
Select a property Select a property
</option> </option>
{sites.map((site) => ( {healthyAccounts.map((account) => (
<optgroup
key={account.accountId}
label={account.email ?? "Google account"}
>
{account.sites.length === 0 ? (
<option disabled>No properties</option>
) : (
account.sites.map((site) => {
const index = options.findIndex(
(option) =>
option.accountId === account.accountId &&
option.siteUrl === site.siteUrl,
);
return (
<option <option
key={site.siteUrl} key={site.siteUrl}
value={site.siteUrl} value={index}
disabled={!site.selectable} disabled={!site.selectable}
> >
{site.siteUrl} {site.siteUrl}
{site.selectable ? "" : " (no access)"} {site.selectable ? "" : " (no access)"}
</option> </option>
);
})
)}
</optgroup>
))} ))}
</select> </select>
</label> </label>
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={onSave} onClick={onSave}
disabled={!selectedSiteUrl || saving} disabled={selectedIndex < 0 || saving}
> >
{saving ? "Saving…" : "Save property"} {saving ? "Saving…" : "Save property"}
</button> </button>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => void startGscLink(window.location.href)}
>
Connect another Google account
</button>
{secondaryAction ? ( {secondaryAction ? (
<button <button
type="button" type="button"

View File

@ -4,7 +4,10 @@ import { Check } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph"; import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning"; import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
import { SitePicker } from "@/client/features/gsc/SitePicker"; import {
SitePicker,
type GscSiteSelection,
} from "@/client/features/gsc/SitePicker";
import { startGscLink } from "@/client/features/gsc/startGscLink"; import { startGscLink } from "@/client/features/gsc/startGscLink";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
@ -15,6 +18,8 @@ import {
} from "@/serverFunctions/gsc"; } from "@/serverFunctions/gsc";
import { getProjects } from "@/serverFunctions/projects"; import { getProjects } from "@/serverFunctions/projects";
const GRANT_STATUS_KEY = ["gscGrantStatus"];
/** /**
* Onboarding step for connecting Google Search Console: link the account-level * Onboarding step for connecting Google Search Console: link the account-level
* OAuth grant, then bind a verified property to the user's first project * OAuth grant, then bind a verified property to the user's first project
@ -47,7 +52,9 @@ export function SearchConsoleOnboardingStep() {
/** Connect + pick-a-property flow, scoped to a known project. */ /** Connect + pick-a-property flow, scoped to a known project. */
function GscConnect({ projectId }: { projectId: string }) { function GscConnect({ projectId }: { projectId: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState(""); const [selection, setSelection] = React.useState<GscSiteSelection | null>(
null,
);
const connectionKey = ["gscConnection", projectId]; const connectionKey = ["gscConnection", projectId];
const connectionQuery = useQuery({ const connectionQuery = useQuery({
@ -65,7 +72,13 @@ function GscConnect({ projectId }: { projectId: string }) {
queryFn: () => listGscSites({ data: { projectId } }), queryFn: () => listGscSites({ data: { projectId } }),
enabled: hasGrant && !connected && !needsSetup, enabled: hasGrant && !connected && !needsSetup,
}); });
const requiresReconnect = Boolean(sitesQuery.data?.requiresReconnect); const accounts = React.useMemo(
() => sitesQuery.data?.accounts ?? [],
[sitesQuery.data?.accounts],
);
const requiresReconnect = accounts.some(
(account) => account.requiresReconnect,
);
React.useEffect(() => { React.useEffect(() => {
if (!requiresReconnect) return; if (!requiresReconnect) return;
@ -73,11 +86,12 @@ function GscConnect({ projectId }: { projectId: string }) {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["gscConnection", projectId], queryKey: ["gscConnection", projectId],
}); });
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
}, [requiresReconnect, queryClient, projectId]); }, [requiresReconnect, queryClient, projectId]);
const setSiteMutation = useMutation({ const setSiteMutation = useMutation({
mutationFn: (siteUrl: string) => mutationFn: (selected: GscSiteSelection) =>
setGscSite({ data: { projectId, siteUrl } }), setGscSite({ data: { projectId, ...selected } }),
onSuccess: () => { onSuccess: () => {
captureClientEvent("gsc:property_select"); captureClientEvent("gsc:property_select");
void queryClient.invalidateQueries({ queryKey: connectionKey }); void queryClient.invalidateQueries({ queryKey: connectionKey });
@ -113,14 +127,13 @@ function GscConnect({ projectId }: { projectId: string }) {
return ( return (
<SitePicker <SitePicker
loading={sitesQuery.isLoading} loading={sitesQuery.isLoading}
error={sitesQuery.isError || requiresReconnect} error={sitesQuery.isError}
sites={sitesQuery.data?.sites ?? []} accounts={accounts}
selectedSiteUrl={selectedSiteUrl} selection={selection}
onSelect={setSelectedSiteUrl} onSelect={setSelection}
onSave={() => onSave={() => selection && setSiteMutation.mutate(selection)}
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
}
saving={setSiteMutation.isPending} saving={setSiteMutation.isPending}
onRetry={() => void sitesQuery.refetch()}
onReconnect={handleConnect} onReconnect={handleConnect}
/> />
); );

View File

@ -22,6 +22,7 @@ export const gscConnections = sqliteTable(
siteUrl: text("site_url").notNull(), siteUrl: text("site_url").notNull(),
// Whose google-search-console grant getAccessToken should use. // Whose google-search-console grant getAccessToken should use.
connectedByUserId: text("connected_by_user_id").notNull(), connectedByUserId: text("connected_by_user_id").notNull(),
gscAccountId: text("gsc_account_id"),
connectedAccountEmail: text("connected_account_email"), connectedAccountEmail: text("connected_account_email"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()

View File

@ -25,6 +25,7 @@ export const gscConnections = pgTable(
siteUrl: text("site_url").notNull(), siteUrl: text("site_url").notNull(),
// Whose google-search-console grant getAccessToken should use. // Whose google-search-console grant getAccessToken should use.
connectedByUserId: text("connected_by_user_id").notNull(), connectedByUserId: text("connected_by_user_id").notNull(),
gscAccountId: text("gsc_account_id"),
connectedAccountEmail: text("connected_account_email"), connectedAccountEmail: text("connected_account_email"),
createdAt: text("created_at").notNull().default(isoNow), createdAt: text("created_at").notNull().default(isoNow),
updatedAt: text("updated_at").notNull().default(isoNow), updatedAt: text("updated_at").notNull().default(isoNow),

View File

@ -44,7 +44,7 @@ export function createBaseAuthConfig() {
"https://accounts.google.com/.well-known/openid-configuration", "https://accounts.google.com/.well-known/openid-configuration",
scopes: [...GSC_OAUTH_SCOPES], scopes: [...GSC_OAUTH_SCOPES],
accessType: "offline", // request a refresh token accessType: "offline", // request a refresh token
prompt: "consent", // force refresh-token issuance on re-consent prompt: "select_account consent",
pkce: true, pkce: true,
}, },
], ],

View File

@ -1,4 +1,4 @@
import { eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { gscConnections } from "@/db/schema"; import { gscConnections } from "@/db/schema";
@ -20,6 +20,7 @@ async function upsert(input: {
organizationId: string; organizationId: string;
siteUrl: string; siteUrl: string;
connectedByUserId: string; connectedByUserId: string;
gscAccountId: string;
connectedAccountEmail: string | null; connectedAccountEmail: string | null;
}): Promise<GscConnection> { }): Promise<GscConnection> {
const [row] = await db const [row] = await db
@ -31,7 +32,8 @@ async function upsert(input: {
siteUrl: input.siteUrl, siteUrl: input.siteUrl,
organizationId: input.organizationId, organizationId: input.organizationId,
connectedByUserId: input.connectedByUserId, connectedByUserId: input.connectedByUserId,
connectedAccountEmail: input.connectedAccountEmail, gscAccountId: input.gscAccountId,
connectedAccountEmail: sql`coalesce(${input.connectedAccountEmail}, ${gscConnections.connectedAccountEmail})`,
updatedAt: sql`(current_timestamp)`, updatedAt: sql`(current_timestamp)`,
}, },
}) })
@ -48,12 +50,19 @@ async function deleteByProjectId(projectId: string): Promise<void> {
.where(eq(gscConnections.projectId, projectId)); .where(eq(gscConnections.projectId, projectId));
} }
/** Whether this user is still the connector for any project's GSC property. */ async function existsForConnectorAccount(
async function existsForConnector(userId: string): Promise<boolean> { userId: string,
gscAccountId: string,
): Promise<boolean> {
const rows = await db const rows = await db
.select({ id: gscConnections.id }) .select({ id: gscConnections.id })
.from(gscConnections) .from(gscConnections)
.where(eq(gscConnections.connectedByUserId, userId)) .where(
and(
eq(gscConnections.connectedByUserId, userId),
eq(gscConnections.gscAccountId, gscAccountId),
),
)
.limit(1); .limit(1);
return rows.length > 0; return rows.length > 0;
} }
@ -62,5 +71,5 @@ export const GscConnectionRepository = {
getByProjectId, getByProjectId,
upsert, upsert,
deleteByProjectId, deleteByProjectId,
existsForConnector, existsForConnectorAccount,
}; };

View File

@ -173,6 +173,7 @@ async function upsertGrant(input: {
ctx.options.account?.encryptOAuthTokens ctx.options.account?.encryptOAuthTokens
? symmetricEncrypt({ key: ctx.secretConfig, data: value }) ? symmetricEncrypt({ key: ctx.secretConfig, data: value })
: value; : value;
const googleAccountId = getGoogleAccountId(input.tokens);
const existing = await db const existing = await db
.select({ id: account.id, refreshToken: account.refreshToken }) .select({ id: account.id, refreshToken: account.refreshToken })
@ -181,12 +182,13 @@ async function upsertGrant(input: {
and( and(
eq(account.userId, input.user.userId), eq(account.userId, input.user.userId),
eq(account.providerId, GSC_OAUTH_PROVIDER_ID), eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
eq(account.accountId, googleAccountId),
), ),
) )
.limit(1); .limit(1);
const accountValues = { const accountValues = {
accountId: getGoogleAccountId(input.tokens), accountId: googleAccountId,
providerId: GSC_OAUTH_PROVIDER_ID, providerId: GSC_OAUTH_PROVIDER_ID,
userId: input.user.userId, userId: input.user.userId,
accessToken: await encrypt(input.tokens.access_token), accessToken: await encrypt(input.tokens.access_token),
@ -274,7 +276,7 @@ export async function createSelfHostedGscAuthorizationUrl(input: {
url.searchParams.set("response_type", "code"); url.searchParams.set("response_type", "code");
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" ")); url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
url.searchParams.set("access_type", "offline"); url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent"); url.searchParams.set("prompt", "select_account consent");
url.searchParams.set("state", state); url.searchParams.set("state", state);
return url.toString(); return url.toString();

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines */
import type { SQL } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => { const mocks = vi.hoisted(() => {
@ -18,22 +20,58 @@ const mocks = vi.hoisted(() => {
} }
} }
const state: { selectRows: Array<{ id: string; accountId: string }> } = {
selectRows: [],
};
type GscClientOptions = { userId: string; gscAccountId?: string };
type GscSite = { siteUrl: string; permissionLevel: string };
const listSites = vi.fn<(opts: GscClientOptions) => Promise<GscSite[]>>();
const getUserInfoEmail =
vi.fn<(opts: GscClientOptions) => Promise<string | null>>();
const querySearchAnalytics =
vi.fn<(opts: GscClientOptions) => Promise<never[]>>();
const deleteWhere = vi
.fn<(condition: SQL) => Promise<void>>()
.mockResolvedValue(undefined);
const dbSelect = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => {
const rows = state.selectRows;
return Object.assign(Promise.resolve(rows), {
limit: vi.fn().mockResolvedValue(rows),
});
}),
})),
}));
return { return {
listSites: vi.fn(), state,
dbSelect,
deleteWhere,
dbDelete: vi.fn(() => ({ where: deleteWhere })),
listSites,
getUserInfoEmail,
querySearchAnalytics,
createGscClient: vi.fn((opts: GscClientOptions) => ({
listSites: () => listSites(opts),
getUserInfoEmail: () => getUserInfoEmail(opts),
querySearchAnalytics: () => querySearchAnalytics(opts),
})),
upsert: vi.fn(), upsert: vi.fn(),
getByProjectId: vi.fn(), getByProjectId: vi.fn(),
deleteByProjectId: vi.fn(), deleteByProjectId: vi.fn(),
existsForConnector: vi.fn(), existsForConnectorAccount: vi.fn(),
dbDelete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
GscApiError, GscApiError,
GscTokenError, GscTokenError,
}; };
}); });
vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/db", () => ({ db: { delete: mocks.dbDelete } })); vi.mock("@/db", () => ({
db: { select: mocks.dbSelect, delete: mocks.dbDelete },
}));
vi.mock("@/server/lib/gscClient", () => ({ vi.mock("@/server/lib/gscClient", () => ({
createGscClient: () => ({ listSites: mocks.listSites }), createGscClient: mocks.createGscClient,
GscApiError: mocks.GscApiError, GscApiError: mocks.GscApiError,
GscTokenError: mocks.GscTokenError, GscTokenError: mocks.GscTokenError,
})); }));
@ -42,42 +80,98 @@ vi.mock("@/server/features/gsc/repositories/GscConnectionRepository", () => ({
upsert: mocks.upsert, upsert: mocks.upsert,
getByProjectId: mocks.getByProjectId, getByProjectId: mocks.getByProjectId,
deleteByProjectId: mocks.deleteByProjectId, deleteByProjectId: mocks.deleteByProjectId,
existsForConnector: mocks.existsForConnector, existsForConnectorAccount: mocks.existsForConnectorAccount,
}, },
})); }));
const baseInput = { const baseInput = {
projectId: "p1", projectId: "p1",
organizationId: "org1", organizationId: "org1",
accountId: "sub-a",
userId: "u1", userId: "u1",
userEmail: "alice@example.com",
}; };
function collectSqlParams(value: unknown): unknown[] {
if (!value || typeof value !== "object") return [];
if ("value" in value && "encoder" in value) {
return [value.value];
}
if (!("queryChunks" in value) || !Array.isArray(value.queryChunks)) return [];
return value.queryChunks.flatMap(collectSqlParams);
}
describe("GscService.setSite", () => { describe("GscService.setSite", () => {
beforeEach(() => { beforeEach(() => {
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
mocks.listSites.mockReset(); mocks.listSites.mockReset();
mocks.getUserInfoEmail.mockReset();
mocks.createGscClient.mockClear();
mocks.upsert.mockReset(); mocks.upsert.mockReset();
}); });
it("upserts a verified property using the connector's identity", async () => { it("upserts a verified property with the selected grant and userinfo email", async () => {
mocks.listSites.mockResolvedValue([ mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" }, { siteUrl: "https://x/", permissionLevel: "siteOwner" },
]); ]);
mocks.getUserInfoEmail.mockResolvedValue("client@example.com");
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" }); mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" }); await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
expect(mocks.createGscClient).toHaveBeenCalledWith({
userId: "u1",
gscAccountId: "sub-a",
});
expect(mocks.upsert).toHaveBeenCalledWith( expect(mocks.upsert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
projectId: "p1", projectId: "p1",
siteUrl: "https://x/", siteUrl: "https://x/",
connectedByUserId: "u1", connectedByUserId: "u1",
connectedAccountEmail: "alice@example.com", gscAccountId: "sub-a",
connectedAccountEmail: "client@example.com",
}), }),
); );
}); });
it("re-saves with a null email when userinfo is unavailable", async () => {
mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
]);
mocks.getUserInfoEmail.mockRejectedValue(new Error("userinfo unavailable"));
mocks.upsert.mockResolvedValue({
siteUrl: "https://x/",
connectedAccountEmail: "previous@example.com",
});
const { GscService } = await import("./GscService");
const result = await GscService.setSite({
...baseInput,
siteUrl: "https://x/",
});
expect(mocks.upsert).toHaveBeenCalledWith(
expect.objectContaining({ connectedAccountEmail: null }),
);
expect(result).toMatchObject({
connectedAccountEmail: "previous@example.com",
});
});
it("rejects a Google sub that is not one of the caller's grants", async () => {
const { GscService } = await import("./GscService");
await expect(
GscService.setSite({
...baseInput,
accountId: "foreign-sub",
siteUrl: "https://x/",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(mocks.createGscClient).not.toHaveBeenCalled();
expect(mocks.upsert).not.toHaveBeenCalled();
});
it("rejects an unverified property with FORBIDDEN", async () => { it("rejects an unverified property with FORBIDDEN", async () => {
mocks.listSites.mockResolvedValue([ mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" }, { siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
@ -90,7 +184,7 @@ describe("GscService.setSite", () => {
expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled();
}); });
it("rejects a property not on the grant with NOT_FOUND", async () => { it("rejects a property not on the selected grant with NOT_FOUND", async () => {
mocks.listSites.mockResolvedValue([ mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" }, { siteUrl: "https://x/", permissionLevel: "siteOwner" },
]); ]);
@ -105,11 +199,57 @@ describe("GscService.setSite", () => {
describe("GscService.listSitesForUserWithGrantStatus", () => { describe("GscService.listSitesForUserWithGrantStatus", () => {
beforeEach(() => { beforeEach(() => {
mocks.state.selectRows = [
{ id: "grant-a", accountId: "sub-a" },
{ id: "grant-b", accountId: "sub-b" },
];
mocks.listSites.mockReset(); mocks.listSites.mockReset();
mocks.getUserInfoEmail.mockReset();
mocks.createGscClient.mockClear();
mocks.dbDelete.mockClear(); mocks.dbDelete.mockClear();
}); });
it("returns available sites when the grant is healthy", async () => { it("lists grants independently and never deletes a dead grant", async () => {
mocks.getUserInfoEmail.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) =>
`${gscAccountId}@example.com`,
);
mocks.listSites.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) => {
if (gscAccountId === "sub-b") throw new mocks.GscTokenError();
return [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }];
},
);
const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({
accounts: [
{
accountId: "sub-a",
email: "sub-a@example.com",
requiresReconnect: false,
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
},
{
accountId: "sub-b",
email: null,
requiresReconnect: true,
sites: [],
},
],
});
expect(mocks.createGscClient).toHaveBeenCalledTimes(2);
expect(mocks.getUserInfoEmail).not.toHaveBeenCalledWith(
expect.objectContaining({ gscAccountId: "sub-b" }),
);
expect(mocks.dbDelete).not.toHaveBeenCalled();
});
it("keeps userinfo failures non-fatal", async () => {
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
mocks.getUserInfoEmail.mockRejectedValue(new Error("userinfo unavailable"));
mocks.listSites.mockResolvedValue([ mocks.listSites.mockResolvedValue([
{ siteUrl: "https://x/", permissionLevel: "siteOwner" }, { siteUrl: "https://x/", permissionLevel: "siteOwner" },
]); ]);
@ -118,23 +258,20 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
await expect( await expect(
GscService.listSitesForUserWithGrantStatus("u1"), GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({ ).resolves.toEqual({
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }], accounts: [
{
accountId: "sub-a",
email: null,
requiresReconnect: false, requiresReconnect: false,
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
},
],
}); });
expect(mocks.dbDelete).not.toHaveBeenCalled();
}); });
it("unlinks the dead grant and asks for reconnect when no token can be minted", async () => { it("marks a grant for reconnect on a GSC 403 without deleting it", async () => {
mocks.listSites.mockRejectedValue(new mocks.GscTokenError()); mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
const { GscService } = await import("./GscService"); mocks.getUserInfoEmail.mockResolvedValue("a@example.com");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({ sites: [], requiresReconnect: true });
expect(mocks.dbDelete).toHaveBeenCalled();
});
it("asks for reconnect without unlinking on a 403 (revoked vs. quota is ambiguous)", async () => {
mocks.listSites.mockRejectedValue( mocks.listSites.mockRejectedValue(
new mocks.GscApiError(403, "Search Console denied access"), new mocks.GscApiError(403, "Search Console denied access"),
); );
@ -142,31 +279,112 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
await expect( await expect(
GscService.listSitesForUserWithGrantStatus("u1"), GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({ sites: [], requiresReconnect: true }); ).resolves.toEqual({
expect(mocks.dbDelete).not.toHaveBeenCalled(); accounts: [
{
accountId: "sub-a",
email: null,
requiresReconnect: true,
sites: [],
},
],
}); });
expect(mocks.getUserInfoEmail).not.toHaveBeenCalled();
it("asks for reconnect without unlinking on a 401", async () => {
mocks.listSites.mockRejectedValue(
new mocks.GscApiError(401, "unauthenticated"),
);
const { GscService } = await import("./GscService");
await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({ sites: [], requiresReconnect: true });
expect(mocks.dbDelete).not.toHaveBeenCalled(); expect(mocks.dbDelete).not.toHaveBeenCalled();
}); });
it("keeps non-auth GSC API errors reportable", async () => { it("keeps non-auth GSC API errors reportable", async () => {
mocks.getUserInfoEmail.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) =>
`${gscAccountId}@example.com`,
);
const rateLimit = new mocks.GscApiError(429, "slow down"); const rateLimit = new mocks.GscApiError(429, "slow down");
mocks.listSites.mockRejectedValue(rateLimit); mocks.listSites.mockImplementation(
async ({ gscAccountId }: { gscAccountId?: string }) => {
if (gscAccountId === "sub-b") throw rateLimit;
return [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }];
},
);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await expect(GscService.listSitesForUserWithGrantStatus("u1")).rejects.toBe( await expect(
GscService.listSitesForUserWithGrantStatus("u1"),
).resolves.toEqual({
accounts: [
{
accountId: "sub-a",
email: "sub-a@example.com",
requiresReconnect: false,
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
},
{
accountId: "sub-b",
email: null,
requiresReconnect: true,
sites: [],
},
],
});
expect(consoleError).toHaveBeenCalledWith(
"Failed to list Search Console sites for account",
"sub-b",
rateLimit, rateLimit,
); );
expect(mocks.dbDelete).not.toHaveBeenCalled(); expect(mocks.dbDelete).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe("GscService.getPerformance", () => {
beforeEach(() => {
mocks.getByProjectId.mockReset();
mocks.querySearchAnalytics.mockReset().mockResolvedValue([]);
mocks.createGscClient.mockClear();
});
it("uses the grant stored on the project connection", async () => {
mocks.getByProjectId.mockResolvedValue({
connectedByUserId: "u1",
connectedAccountEmail: "a@example.com",
gscAccountId: "sub-a",
siteUrl: "https://x/",
});
const { GscService } = await import("./GscService");
await GscService.getPerformance({
projectId: "p1",
startDate: "2026-01-01",
endDate: "2026-01-31",
});
expect(mocks.createGscClient).toHaveBeenCalledWith({
userId: "u1",
gscAccountId: "sub-a",
});
});
it("passes undefined for the legacy null-account fallback", async () => {
mocks.getByProjectId.mockResolvedValue({
connectedByUserId: "u1",
connectedAccountEmail: null,
gscAccountId: null,
siteUrl: "https://x/",
});
const { GscService } = await import("./GscService");
await GscService.getPerformance({
projectId: "p1",
startDate: "2026-01-01",
endDate: "2026-01-31",
});
expect(mocks.createGscClient).toHaveBeenCalledWith({
userId: "u1",
gscAccountId: undefined,
});
}); });
}); });
@ -174,25 +392,36 @@ describe("GscService.disconnect", () => {
beforeEach(() => { beforeEach(() => {
mocks.getByProjectId.mockReset(); mocks.getByProjectId.mockReset();
mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined); mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined);
mocks.existsForConnector.mockReset(); mocks.existsForConnectorAccount.mockReset();
mocks.dbDelete.mockClear(); mocks.dbDelete.mockClear();
mocks.deleteWhere.mockClear();
}); });
it("unlinks the connector's grant when they disconnect their last project", async () => { it("unlinks only the disconnected account when it is no longer used", async () => {
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" }); mocks.getByProjectId.mockResolvedValue({
mocks.existsForConnector.mockResolvedValue(false); connectedByUserId: "u1",
gscAccountId: "sub-b",
});
mocks.existsForConnectorAccount.mockResolvedValue(false);
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" }); await GscService.disconnect({ projectId: "p1", userId: "u1" });
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1"); expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1"); expect(mocks.existsForConnectorAccount).toHaveBeenCalledWith("u1", "sub-b");
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked expect(mocks.dbDelete).toHaveBeenCalledTimes(1);
const whereCondition = mocks.deleteWhere.mock.calls[0]?.[0];
expect(collectSqlParams(whereCondition)).toEqual(
expect.arrayContaining(["u1", "google-search-console", "sub-b"]),
);
}); });
it("keeps the grant when the connector still has another connected project", async () => { it("keeps the grant when the same account powers another project", async () => {
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" }); mocks.getByProjectId.mockResolvedValue({
mocks.existsForConnector.mockResolvedValue(true); connectedByUserId: "u1",
gscAccountId: "sub-b",
});
mocks.existsForConnectorAccount.mockResolvedValue(true);
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" }); await GscService.disconnect({ projectId: "p1", userId: "u1" });
@ -200,27 +429,40 @@ describe("GscService.disconnect", () => {
expect(mocks.dbDelete).not.toHaveBeenCalled(); expect(mocks.dbDelete).not.toHaveBeenCalled();
}); });
it("never revokes a grant when a different member disconnects the connection", async () => { it("never revokes a grant when another member disconnects", async () => {
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "owner" }); mocks.getByProjectId.mockResolvedValue({
connectedByUserId: "owner",
gscAccountId: "sub-b",
});
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "other-member" }); await GscService.disconnect({ projectId: "p1", userId: "other-member" });
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1"); expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
expect(mocks.existsForConnector).not.toHaveBeenCalled();
expect(mocks.dbDelete).not.toHaveBeenCalled(); expect(mocks.dbDelete).not.toHaveBeenCalled();
}); });
it("unlinks the caller's dangling grant when no property was ever bound", async () => { it("deletes no grants for a legacy null-account connection", async () => {
// Linked Google but never picked a property → no connection row. Disconnect mocks.getByProjectId.mockResolvedValue({
// should still drop the caller's own grant. connectedByUserId: "u1",
mocks.getByProjectId.mockResolvedValue(null); gscAccountId: null,
mocks.existsForConnector.mockResolvedValue(false); });
const { GscService } = await import("./GscService"); const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" }); await GscService.disconnect({ projectId: "p1", userId: "u1" });
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1"); expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
expect(mocks.dbDelete).not.toHaveBeenCalled();
});
it("deletes no grants when no property was bound", async () => {
mocks.getByProjectId.mockResolvedValue(null);
const { GscService } = await import("./GscService");
await GscService.disconnect({ projectId: "p1", userId: "u1" });
expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
expect(mocks.dbDelete).not.toHaveBeenCalled();
}); });
}); });

View File

@ -33,8 +33,12 @@ type GscPerformanceResult = {
}; };
type GscSiteListResult = { type GscSiteListResult = {
sites: GscSite[]; accounts: Array<{
accountId: string;
email: string | null;
requiresReconnect: boolean; requiresReconnect: boolean;
sites: GscSite[];
}>;
}; };
/** Thrown when a project has no connected GSC property. */ /** Thrown when a project has no connected GSC property. */
@ -65,15 +69,21 @@ async function userHasGrant(userId: string): Promise<boolean> {
return rows.length > 0; return rows.length > 0;
} }
/** List verified properties available on a user's google-search-console grant. */ async function listGrantsForUser(userId: string) {
async function listSitesForUser(userId: string): Promise<GscSite[]> { return db
return createGscClient({ userId }).listSites(); .select({ id: account.id, accountId: account.accountId })
.from(account)
.where(
and(
eq(account.userId, userId),
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
),
);
} }
/** Expected ways a stored grant fails to reach Search Console: no token could be /** Expected ways a stored grant fails to reach Search Console: no token could be
* minted (refresh token revoked or expired), or Google rejected the call * minted (refresh token revoked or expired), or Google rejected the call
* (401/403). These surface a reconnect prompt instead of being routed through * (401/403). These surface a reconnect prompt without fault logging. */
* error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */
export function isExpectedGrantFailure(error: unknown): boolean { export function isExpectedGrantFailure(error: unknown): boolean {
if (error instanceof GscTokenError) return true; if (error instanceof GscTokenError) return true;
return ( return (
@ -82,30 +92,49 @@ export function isExpectedGrantFailure(error: unknown): boolean {
); );
} }
/** List properties for the picker UI. When the stored grant can't currently
* reach GSC, return a reconnect signal instead of throwing, so an expected
* external-auth failure doesn't land in error tracking.
*
* Only a GscTokenError unlinks the stored grant the one unambiguous "this
* grant is dead" signal (Better Auth couldn't mint/refresh a token, i.e. the
* user revoked access or the refresh token expired). A bare 401/403 from
* sites.list is left in place: Search Console also returns 403 for quota/rate
* limits, so destroying the grant there would force needless reconnects across
* every project on it. Reconnecting re-upserts the grant either way. */
async function listSitesForUserWithGrantStatus( async function listSitesForUserWithGrantStatus(
userId: string, userId: string,
): Promise<GscSiteListResult> { ): Promise<GscSiteListResult> {
const grants = await listGrantsForUser(userId);
const accounts = await Promise.all(
grants.map(async (grant) => {
const client = createGscClient({
userId,
gscAccountId: grant.accountId,
});
try { try {
return { sites: await listSitesForUser(userId), requiresReconnect: false }; const sites = await client.listSites();
let email: string | null = null;
try {
email = await client.getUserInfoEmail();
} catch {
email = null;
}
return {
accountId: grant.accountId,
email,
requiresReconnect: false,
sites,
};
} catch (error) { } catch (error) {
if (!isExpectedGrantFailure(error)) { if (!isExpectedGrantFailure(error)) {
throw error; console.error(
"Failed to list Search Console sites for account",
grant.accountId,
error,
);
} }
if (error instanceof GscTokenError) { return {
await unlinkUserGrant(userId); accountId: grant.accountId,
} email: null,
return { sites: [], requiresReconnect: true }; requiresReconnect: true,
sites: [],
};
} }
}),
);
return { accounts };
} }
/** Map a verified property to a project. Rejects unverified properties and /** Map a verified property to a project. Rejects unverified properties and
@ -114,10 +143,22 @@ async function setSite(input: {
projectId: string; projectId: string;
organizationId: string; organizationId: string;
siteUrl: string; siteUrl: string;
accountId: string;
userId: string; userId: string;
userEmail: string;
}): Promise<GscConnection> { }): Promise<GscConnection> {
const sites = await listSitesForUser(input.userId); const grants = await listGrantsForUser(input.userId);
if (!grants.some((grant) => grant.accountId === input.accountId)) {
throw new AppError(
"NOT_FOUND",
"That Google account isn't connected to your OpenSEO account.",
);
}
const client = createGscClient({
userId: input.userId,
gscAccountId: input.accountId,
});
const sites = await client.listSites();
const match = sites.find((s) => s.siteUrl === input.siteUrl); const match = sites.find((s) => s.siteUrl === input.siteUrl);
if (!match) { if (!match) {
throw new AppError( throw new AppError(
@ -131,23 +172,33 @@ async function setSite(input: {
"You don't have verified access to that Search Console property.", "You don't have verified access to that Search Console property.",
); );
} }
let connectedAccountEmail: string | null = null;
try {
connectedAccountEmail = await client.getUserInfoEmail();
} catch {
connectedAccountEmail = null;
}
return GscConnectionRepository.upsert({ return GscConnectionRepository.upsert({
projectId: input.projectId, projectId: input.projectId,
organizationId: input.organizationId, organizationId: input.organizationId,
siteUrl: input.siteUrl, siteUrl: input.siteUrl,
connectedByUserId: input.userId, connectedByUserId: input.userId,
connectedAccountEmail: input.userEmail, gscAccountId: input.accountId,
connectedAccountEmail,
}); });
} }
/** Remove this user's google-search-console grant (stored OAuth tokens). */ async function unlinkUserGrant(
async function unlinkUserGrant(userId: string): Promise<void> { userId: string,
gscAccountId: string,
): Promise<void> {
await db await db
.delete(account) .delete(account)
.where( .where(
and( and(
eq(account.userId, userId), eq(account.userId, userId),
eq(account.providerId, GSC_OAUTH_PROVIDER_ID), eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
eq(account.accountId, gscAccountId),
), ),
); );
} }
@ -160,19 +211,16 @@ async function disconnect(input: {
input.projectId, input.projectId,
); );
await GscConnectionRepository.deleteByProjectId(input.projectId); await GscConnectionRepository.deleteByProjectId(input.projectId);
// Clean up the caller's *own* OAuth grant once none of their projects still if (
// use it. Safe by construction: unlinkUserGrant only ever deletes the connection?.gscAccountId &&
// caller's account row, never another member's. We skip cleanup only when the connection.connectedByUserId === input.userId
// binding we removed belonged to a *different* member, so unbinding their ) {
// property never revokes the caller's unrelated grant. A null connection const stillUsed = await GscConnectionRepository.existsForConnectorAccount(
// means the caller linked Google but never picked a property — that dangling
// grant is theirs to drop.
if (!connection || connection.connectedByUserId === input.userId) {
const stillUsed = await GscConnectionRepository.existsForConnector(
input.userId, input.userId,
connection.gscAccountId,
); );
if (!stillUsed) { if (!stillUsed) {
await unlinkUserGrant(input.userId); await unlinkUserGrant(input.userId, connection.gscAccountId);
} }
} }
} }
@ -188,7 +236,10 @@ async function getPerformance(
throw new GscNotConnectedError(input.projectId); throw new GscNotConnectedError(input.projectId);
} }
const request = buildSearchAnalyticsRequest(input); const request = buildSearchAnalyticsRequest(input);
const client = createGscClient({ userId: connection.connectedByUserId }); const client = createGscClient({
userId: connection.connectedByUserId,
gscAccountId: connection.gscAccountId ?? undefined,
});
const rows = await client.querySearchAnalytics(connection.siteUrl, request); const rows = await client.querySearchAnalytics(connection.siteUrl, request);
return { return {
siteUrl: connection.siteUrl, siteUrl: connection.siteUrl,
@ -225,7 +276,10 @@ async function inspectUrls(input: {
if (!connection) { if (!connection) {
throw new GscNotConnectedError(input.projectId); throw new GscNotConnectedError(input.projectId);
} }
const client = createGscClient({ userId: connection.connectedByUserId }); const client = createGscClient({
userId: connection.connectedByUserId,
gscAccountId: connection.gscAccountId ?? undefined,
});
const results: GscUrlInspection[] = []; const results: GscUrlInspection[] = [];
for (const url of input.urls) { for (const url of input.urls) {
try { try {

View File

@ -39,6 +39,52 @@ describe("gscClient", () => {
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" }); expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
}); });
it("targets the selected Better Auth grant by Google sub", async () => {
mocks.fetch.mockResolvedValue(jsonResponse({ siteEntry: [] }));
const { createGscClient } = await import("./gscClient");
await createGscClient({
userId: "u1",
gscAccountId: "google-sub-a",
}).listSites();
expect(mocks.getAccessToken).toHaveBeenCalledWith({
body: {
providerId: "google-search-console",
userId: "u1",
accountId: "google-sub-a",
},
});
});
it("omits accountId for the legacy null-account fallback", async () => {
mocks.fetch.mockResolvedValue(jsonResponse({ siteEntry: [] }));
const { createGscClient } = await import("./gscClient");
await createGscClient({ userId: "u1" }).listSites();
expect(mocks.getAccessToken).toHaveBeenCalledWith({
body: { providerId: "google-search-console", userId: "u1" },
});
});
it("fetches the Google account email from userinfo", async () => {
mocks.fetch.mockResolvedValue(
jsonResponse({ email: "client@example.com" }),
);
const { createGscClient } = await import("./gscClient");
const email = await createGscClient({
userId: "u1",
gscAccountId: "google-sub-a",
}).getUserInfoEmail();
expect(email).toBe("client@example.com");
const [url, init] = mocks.fetch.mock.calls[0];
expect(url).toBe("https://openidconnect.googleapis.com/v1/userinfo");
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
});
it("encodes the siteUrl in the searchAnalytics path (both property forms)", async () => { it("encodes the siteUrl in the searchAnalytics path (both property forms)", async () => {
mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] })); mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] }));
const { createGscClient } = await import("./gscClient"); const { createGscClient } = await import("./gscClient");

View File

@ -2,6 +2,7 @@ import { getAuth } from "@/lib/auth";
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc"; import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3"; const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
/** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */ /** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */
export class GscApiError extends Error { export class GscApiError extends Error {
@ -99,7 +100,10 @@ function messageForStatus(status: number, body: string): string {
* meter credits GSC is first-party data with no per-call cost. Access tokens * meter credits GSC is first-party data with no per-call cost. Access tokens
* are minted (and auto-refreshed) by Better Auth from the connector's stored * are minted (and auto-refreshed) by Better Auth from the connector's stored
* google-search-console grant. */ * google-search-console grant. */
export function createGscClient(opts: { userId: string }) { export function createGscClient(opts: {
userId: string;
gscAccountId?: string;
}) {
async function getToken(): Promise<string> { async function getToken(): Promise<string> {
let result: { accessToken?: string } | undefined; let result: { accessToken?: string } | undefined;
try { try {
@ -108,7 +112,11 @@ export function createGscClient(opts: { userId: string }) {
// Works in every auth mode — self-hosted builds the same Better Auth // Works in every auth mode — self-hosted builds the same Better Auth
// instance once BETTER_AUTH_SECRET is set. // instance once BETTER_AUTH_SECRET is set.
result = await getAuth().api.getAccessToken({ result = await getAuth().api.getAccessToken({
body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId }, body: {
providerId: GSC_OAUTH_PROVIDER_ID,
userId: opts.userId,
...(opts.gscAccountId ? { accountId: opts.gscAccountId } : {}),
},
}); });
} catch (error) { } catch (error) {
throw new GscTokenError( throw new GscTokenError(
@ -150,6 +158,11 @@ export function createGscClient(opts: { userId: string }) {
} }
return { return {
async getUserInfoEmail(): Promise<string | null> {
const data = await request<{ email?: unknown }>(GOOGLE_USERINFO_URL);
return typeof data.email === "string" ? data.email : null;
},
/** Webmasters API `sites.list` — the verified properties on the grant. */ /** Webmasters API `sites.list` — the verified properties on the grant. */
async listSites(): Promise<GscSite[]> { async listSites(): Promise<GscSite[]> {
const data = await request<{ siteEntry?: GscSite[] }>( const data = await request<{ siteEntry?: GscSite[] }>(

View File

@ -110,9 +110,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: [] }), okResult({ items: [] }),
); );
await runWithMcpToolAuthContext(authContext, () => await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
wrapped({}, toolExtra),
);
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1); expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
@ -134,9 +132,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: "not-an-array" }), okResult({ items: "not-an-array" }),
); );
await runWithMcpToolAuthContext(authContext, () => await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
wrapped({}, toolExtra),
);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call", event: "mcp:tool_call",

View File

@ -15,6 +15,7 @@ import {
const projectScopedSchema = z.object({ projectId: z.string().min(1) }); const projectScopedSchema = z.object({ projectId: z.string().min(1) });
const setSiteSchema = projectScopedSchema.extend({ const setSiteSchema = projectScopedSchema.extend({
accountId: z.string().min(1),
siteUrl: z.string().min(1), siteUrl: z.string().min(1),
}); });
const startSelfHostedLinkSchema = z.object({ const startSelfHostedLinkSchema = z.object({
@ -59,13 +60,27 @@ export const listGscSites = createServerFn({ method: "POST" })
GscService.listSitesForUserWithGrantStatus(context.userId), GscService.listSitesForUserWithGrantStatus(context.userId),
GscService.getConnection(context.projectId), GscService.getConnection(context.projectId),
]); ]);
let legacySelectionMatched = false;
return { return {
requiresReconnect: siteList.requiresReconnect, accounts: siteList.accounts.map((grant) => ({
sites: siteList.sites.map((s) => ({ accountId: grant.accountId,
siteUrl: s.siteUrl, email: grant.email,
permissionLevel: s.permissionLevel, requiresReconnect: grant.requiresReconnect,
selectable: s.permissionLevel !== "siteUnverifiedUser", sites: grant.sites.map((site) => {
isSelected: s.siteUrl === connection?.siteUrl, const isSelected = connection?.gscAccountId
? connection.gscAccountId === grant.accountId &&
connection.siteUrl === site.siteUrl
: !legacySelectionMatched && connection?.siteUrl === site.siteUrl;
if (!connection?.gscAccountId && isSelected) {
legacySelectionMatched = true;
}
return {
siteUrl: site.siteUrl,
permissionLevel: site.permissionLevel,
selectable: site.permissionLevel !== "siteUnverifiedUser",
isSelected,
};
}),
})), })),
}; };
}); });
@ -77,9 +92,9 @@ export const setGscSite = createServerFn({ method: "POST" })
const connection = await GscService.setSite({ const connection = await GscService.setSite({
projectId: context.projectId, projectId: context.projectId,
organizationId: context.organizationId, organizationId: context.organizationId,
accountId: data.accountId,
siteUrl: data.siteUrl, siteUrl: data.siteUrl,
userId: context.userId, userId: context.userId,
userEmail: context.userEmail,
}); });
waitUntil( waitUntil(
captureServerEvent({ captureServerEvent({