# GSC Connection + Property Mapping Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let an org owner connect Google Search Console and map a verified GSC property to each site (Settings → Intégrations), read-only for members.

**Architecture:** Org-level, owner-only connection derived from the owner's linked Google account (Approach A — no connection table). A `sites.gscProperty` column stores the mapping. Server actions (owner-gated) resolve the current session's (owner's) Google access token via Better Auth and call the GSC REST API to list verified properties.

**Tech Stack:** Next 16 (App Router, server actions), Better Auth 1.6.14 (`account.accountLinking`, `getAccessToken`, `linkSocial`), Drizzle + Postgres, shadcn, bun, biome.

Spec: `docs/superpowers/specs/2026-06-05-gsc-connection-mapping-design.md`

---

## File structure

- Modify `src/lib/db/domain-schema.ts` — add `sites.gscProperty`.
- Generated migration `drizzle/NNNN_*.sql` + `drizzle/meta/*`.
- Modify `src/lib/auth.ts` — enable `account.accountLinking` for google.
- Create `src/lib/gsc/types.ts` — `GscProperty`.
- Create `src/lib/gsc/client.ts` — `fetchGscProperties(accessToken)` (REST).
- Create `src/lib/gsc/client.test.ts` — parse/filter tests.
- Create `src/lib/gsc/token.ts` — `getGscAccessToken()` (current session/owner).
- Create `src/lib/gsc/actions.ts` — `getGscStatusAction`, `setSiteGscPropertyAction` (owner-gated).
- Create `src/lib/gsc/actions.test.ts` — owner guard + persistence (DB-gated).
- Modify `src/components/app-sidebar.tsx` — add "Intégrations" nav item.
- Create `src/app/(app)/settings/integrations/page.tsx` — server page.
- Create `src/components/gsc-integrations.tsx` — client island.

---

## Task 1: Add `sites.gscProperty` column + migration

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (sites table, ends ~line 44)
- Generated: `drizzle/*.sql`, `drizzle/meta/*`

- [ ] **Step 1: Add the column**

In `src/lib/db/domain-schema.ts`, inside `export const sites = pgTable("sites", { ... })`, add after `targetCountries`:

```ts
  // Mapped Google Search Console property (e.g. "sc-domain:example.com"). Null = unmapped.
  gscProperty: text("gsc_property"),
```

- [ ] **Step 2: Generate the migration**

Run: `bun run db:generate`
Expected: a new `drizzle/NNNN_*.sql` adding `ALTER TABLE "sites" ADD COLUMN "gsc_property" text;`

- [ ] **Step 3: Inspect the migration**

Run: `cat $(ls -t drizzle/*.sql | head -1)`
Expected: ONLY the `sites` ADD COLUMN (no unrelated changes). If other changes appear, STOP and investigate.

- [ ] **Step 4: Apply to dev + test DBs**

Run:
```bash
bun run db:migrate
DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun run src/lib/db/migrate.ts
```
Expected: `✓ migrations applied` for both.

- [ ] **Step 5: Verify column exists**

Run: `psql "postgresql://humperdoo@localhost:5432/seo_insight_test" -tc "select column_name from information_schema.columns where table_name='sites' and column_name='gsc_property';"`
Expected: `gsc_property`

- [ ] **Step 6: Typecheck + commit**

```bash
bunx tsc --noEmit
git add src/lib/db/domain-schema.ts drizzle/
git commit -m "feat(gsc): add sites.gscProperty column + migration"
```

---

## Task 2: Enable Google account linking

**Files:**
- Modify: `src/lib/auth.ts:41` (the `account:` block)

- [ ] **Step 1: Enable accountLinking for google**

Replace `account: { encryptOAuthTokens: true },` with:

```ts
  account: {
    encryptOAuthTokens: true,
    // Allow linking a Google account (with the webmasters scope) to any user —
    // required so an owner who signed up with email/password can connect GSC.
    accountLinking: { enabled: true, trustedProviders: ["google"] },
  },
```

- [ ] **Step 2: Typecheck**

Run: `bunx tsc --noEmit`
Expected: exit 0.

- [ ] **Step 3: Verify the app still boots (no config error)**

Run: `d3k agent-browser open http://localhost:3000/login` then `d3k errors -n 5`
Expected: `/login` renders; no betterAuth/accountLinking init error.

- [ ] **Step 4: Commit**

```bash
git add src/lib/auth.ts
git commit -m "feat(gsc): enable Google account linking (webmasters scope)"
```

---

## Task 3: GSC property type + REST client

**Files:**
- Create: `src/lib/gsc/types.ts`
- Create: `src/lib/gsc/client.ts`
- Test: `src/lib/gsc/client.test.ts`

- [ ] **Step 1: Define the type**

`src/lib/gsc/types.ts`:
```ts
// src/lib/gsc/types.ts — Google Search Console domain types.

/** A verified GSC property as returned by webmasters/v3/sites. */
export type GscProperty = {
  /** e.g. "sc-domain:example.com" or "https://example.com/" */
  siteUrl: string;
  /** "siteOwner" | "siteFullUser" | "siteRestrictedUser" | "siteUnverifiedUser" */
  permissionLevel: string;
};
```

- [ ] **Step 2: Write the failing test**

`src/lib/gsc/client.test.ts`:
```ts
import { expect, test } from "bun:test";
import { parseGscSites } from "./client";

test("parseGscSites keeps verified properties and drops unverified", () => {
  const raw = {
    siteEntry: [
      { siteUrl: "sc-domain:example.com", permissionLevel: "siteOwner" },
      { siteUrl: "https://blog.example.com/", permissionLevel: "siteFullUser" },
      { siteUrl: "https://nope.example.com/", permissionLevel: "siteUnverifiedUser" },
    ],
  };
  expect(parseGscSites(raw)).toEqual([
    { siteUrl: "sc-domain:example.com", permissionLevel: "siteOwner" },
    { siteUrl: "https://blog.example.com/", permissionLevel: "siteFullUser" },
  ]);
});

test("parseGscSites tolerates missing/empty siteEntry", () => {
  expect(parseGscSites({})).toEqual([]);
  expect(parseGscSites({ siteEntry: [] })).toEqual([]);
});
```

- [ ] **Step 2b: Run it — verify it fails**

Run: `bun test src/lib/gsc/client.test.ts`
Expected: FAIL (`parseGscSites` not exported).

- [ ] **Step 3: Implement the client**

`src/lib/gsc/client.ts`:
```ts
// src/lib/gsc/client.ts — Google Search Console REST helper (no googleapis dep).
import "server-only";
import type { GscProperty } from "./types";

const SITES_URL = "https://www.googleapis.com/webmasters/v3/sites";
const UNVERIFIED = "siteUnverifiedUser";

/** Pick the verified properties out of a webmasters/v3/sites response body. */
export function parseGscSites(body: unknown): GscProperty[] {
  const entries = (body as { siteEntry?: unknown })?.siteEntry;
  if (!Array.isArray(entries)) return [];
  return entries
    .filter(
      (e): e is GscProperty =>
        typeof (e as GscProperty)?.siteUrl === "string" &&
        typeof (e as GscProperty)?.permissionLevel === "string" &&
        (e as GscProperty).permissionLevel !== UNVERIFIED,
    )
    .map((e) => ({ siteUrl: e.siteUrl, permissionLevel: e.permissionLevel }));
}

/** List the verified GSC properties for the given access token. */
export async function fetchGscProperties(
  accessToken: string,
): Promise<GscProperty[]> {
  const res = await fetch(SITES_URL, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) {
    throw new Error(`GSC sites.list failed: HTTP ${res.status}`);
  }
  return parseGscSites(await res.json());
}
```

- [ ] **Step 4: Run tests — verify they pass**

Run: `bun test src/lib/gsc/client.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Lint + commit**

```bash
bunx biome check --write src/lib/gsc/client.ts src/lib/gsc/types.ts src/lib/gsc/client.test.ts
bunx tsc --noEmit
git add src/lib/gsc/
git commit -m "feat(gsc): REST client to list verified Search Console properties"
```

---

## Task 4: Access-token resolver

**Files:**
- Create: `src/lib/gsc/token.ts`

> No unit test: this is a thin wrapper over Better Auth that needs a real session. It's exercised via the action test (Task 5) and live verification (Task 8).

- [ ] **Step 1: Implement the resolver**

`src/lib/gsc/token.ts`:
```ts
// src/lib/gsc/token.ts — resolve the current session user's Google access token.
import "server-only";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

/**
 * Fresh Google access token for the current session user (the owner, in slice 1).
 * Returns null when the user has no linked Google account / no valid grant.
 */
export async function getGscAccessToken(): Promise<string | null> {
  try {
    const res = await auth.api.getAccessToken({
      body: { providerId: "google" },
      headers: await headers(),
    });
    return res?.accessToken ?? null;
  } catch {
    return null;
  }
}
```

- [ ] **Step 2: Typecheck**

Run: `bunx tsc --noEmit`
Expected: exit 0. (If `res.accessToken` is mis-typed, inspect the return type of `auth.api.getAccessToken` and adjust the property name.)

- [ ] **Step 3: Commit**

```bash
git add src/lib/gsc/token.ts
git commit -m "feat(gsc): resolve current user's Google access token"
```

---

## Task 5: Server actions (owner-gated)

**Files:**
- Create: `src/lib/gsc/actions.ts`
- Test: `src/lib/gsc/actions.test.ts`

- [ ] **Step 1: Implement the actions**

`src/lib/gsc/actions.ts`:
```ts
// src/lib/gsc/actions.ts — GSC connection status + per-site property mapping.
"use server";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { getActiveOrgId } from "@/lib/active-org";
import { db } from "@/lib/db/client";
import { sites as sitesTable } from "@/lib/db/domain-schema";
import { fetchGscProperties } from "./client";
import { getGscAccessToken } from "./token";
import type { GscProperty } from "./types";

export type GscSite = {
  id: string;
  name: string;
  url: string | null;
  gscProperty: string | null;
};

export type GscStatus = {
  /** Is the current user the org owner (allowed to connect/map)? */
  canManage: boolean;
  /** Does the current user have a usable Google connection? */
  connected: boolean;
  /** Verified GSC properties (only populated for the owner when connected). */
  properties: GscProperty[];
  sites: GscSite[];
};

async function isOwner(): Promise<boolean> {
  const member = await auth.api.getActiveMember({ headers: await headers() });
  return member?.role === "owner";
}

async function orgSites(orgId: string): Promise<GscSite[]> {
  const rows = await db
    .select({
      id: sitesTable.id,
      name: sitesTable.name,
      url: sitesTable.url,
      gscProperty: sitesTable.gscProperty,
    })
    .from(sitesTable)
    .where(eq(sitesTable.orgId, orgId));
  return rows;
}

/** Status for the integrations page. */
export async function getGscStatusAction(): Promise<GscStatus> {
  const orgId = await getActiveOrgId();
  const canManage = await isOwner();
  const sites = await orgSites(orgId);
  if (!canManage) {
    // Members: read-only. "connected" reflects whether any site is mapped.
    return {
      canManage: false,
      connected: sites.some((s) => s.gscProperty),
      properties: [],
      sites,
    };
  }
  const token = await getGscAccessToken();
  if (!token) {
    return { canManage: true, connected: false, properties: [], sites };
  }
  let properties: GscProperty[] = [];
  try {
    properties = await fetchGscProperties(token);
  } catch {
    properties = [];
  }
  return { canManage: true, connected: true, properties, sites };
}

export type SetSiteGscResult =
  | { ok: true }
  | { ok: false; error: string };

/** Map (or clear) a site's GSC property. Owner-only. */
export async function setSiteGscPropertyAction(
  siteId: string,
  property: string | null,
): Promise<SetSiteGscResult> {
  if (!(await isOwner())) {
    return { ok: false, error: "Action réservée au propriétaire." };
  }
  const orgId = await getActiveOrgId();
  // Validate the property belongs to the owner's verified set (when setting).
  if (property) {
    const token = await getGscAccessToken();
    const props = token ? await fetchGscProperties(token) : [];
    if (!props.some((p) => p.siteUrl === property)) {
      return { ok: false, error: "Propriété GSC non vérifiée ou inconnue." };
    }
  }
  const updated = await db
    .update(sitesTable)
    .set({ gscProperty: property })
    .where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (updated.length === 0) {
    return { ok: false, error: "Site introuvable dans cette organisation." };
  }
  revalidatePath("/settings/integrations");
  return { ok: true };
}
```

- [ ] **Step 2: Write the failing test (owner guard + scoping)**

`src/lib/gsc/actions.test.ts`:
```ts
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { member, organization, user } from "@/lib/db/auth-schema";
import { client, db } from "@/lib/db/client";
import { sites as sitesTable } from "@/lib/db/domain-schema";

// Controls the mocked role/token without a real session.
let ROLE: "owner" | "staff" = "owner";
mock.module("@/lib/active-org", () => ({
  getActiveOrgId: async () => "gsc-test-org",
  getActiveUserId: async () => "gsc-test-user",
}));
mock.module("@/lib/auth", () => ({
  auth: { api: { getActiveMember: async () => ({ role: ROLE }) } },
}));
mock.module("./token", () => ({
  getGscAccessToken: async () => "fake-token",
}));
mock.module("./client", () => ({
  fetchGscProperties: async () => [
    { siteUrl: "sc-domain:example.com", permissionLevel: "siteOwner" },
  ],
}));

const { setSiteGscPropertyAction } = await import("./actions");

beforeEach(async () => {
  await assertTestDb();
  ROLE = "owner";
  await db.delete(sitesTable).where(eq(sitesTable.orgId, "gsc-test-org"));
  await db.delete(member).where(eq(member.organizationId, "gsc-test-org"));
  await db.delete(organization).where(eq(organization.id, "gsc-test-org"));
  await db.delete(user).where(eq(user.id, "gsc-test-user"));
  await db.insert(user).values({ id: "gsc-test-user", name: "U", email: `gsc-${Date.now()}@example.com`, emailVerified: true });
  await db.insert(organization).values({ id: "gsc-test-org", name: "O", slug: `gsc-${Date.now()}`, createdAt: new Date() });
  await db.insert(sitesTable).values({ id: "gsc-test-site", orgId: "gsc-test-org", ownerUserId: "gsc-test-user", name: "S", url: "https://example.com" });
});

afterAll(async () => {
  await client.end();
});

test("owner can map a verified property", async () => {
  const res = await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  expect(res.ok).toBe(true);
  const [row] = await db.select().from(sitesTable).where(eq(sitesTable.id, "gsc-test-site"));
  expect(row.gscProperty).toBe("sc-domain:example.com");
});

test("rejects an unverified property", async () => {
  const res = await setSiteGscPropertyAction("gsc-test-site", "sc-domain:evil.com");
  expect(res.ok).toBe(false);
});

test("non-owner is refused", async () => {
  ROLE = "staff";
  const res = await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  expect(res.ok).toBe(false);
});
```

- [ ] **Step 2b: Run it — verify it fails**

Run: `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/gsc/actions.test.ts`
Expected: FAIL initially if actions not present — but actions exist from Step 1, so this run should actually PASS. If it fails, read the error and fix the action (not the test).

- [ ] **Step 3: Run tests — verify they pass**

Run: `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/gsc/actions.test.ts`
Expected: PASS (3 tests). Note: `--conditions=react-server` is required because `token.ts`/`client.ts` import `server-only`.

- [ ] **Step 4: Lint + typecheck + commit**

```bash
bunx biome check --write src/lib/gsc/actions.ts src/lib/gsc/actions.test.ts
bunx tsc --noEmit
git add src/lib/gsc/
git commit -m "feat(gsc): owner-gated status + set-property server actions"
```

---

## Task 6: Add "Intégrations" nav entry

**Files:**
- Modify: `src/components/app-sidebar.tsx` (Settings sub-items, after "Team")

- [ ] **Step 1: Add the nav item**

In the Settings `items` array, after the `{ title: "Team", url: "/settings/team" }` entry, add:

```ts
        {
          title: "Intégrations",
          url: "/settings/integrations",
        },
```

- [ ] **Step 2: Typecheck + commit**

```bash
bunx tsc --noEmit
git add src/components/app-sidebar.tsx
git commit -m "feat(gsc): add Settings -> Intégrations nav entry"
```

---

## Task 7: Integrations server page

**Files:**
- Create: `src/app/(app)/settings/integrations/page.tsx`

> Depends on `<GscIntegrations>` (Task 8). Build the page first referencing it; tsc will be green only after Task 8. Commit at the end of Task 8.

- [ ] **Step 1: Write the page**

`src/app/(app)/settings/integrations/page.tsx`:
```tsx
// src/app/(app)/settings/integrations/page.tsx — Settings → Intégrations (GSC).
import { DashboardHeader } from "@/components/dashboard-header";
import { GscIntegrations } from "@/components/gsc-integrations";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { getGscStatusAction } from "@/lib/gsc/actions";

export default async function IntegrationsPage() {
  const status = await getGscStatusAction();
  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>
              <BreadcrumbLink href="/settings/account">
                Paramètres
              </BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Intégrations</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <div className="p-4">
        <GscIntegrations initial={status} />
      </div>
    </>
  );
}
```

> Verify the `DashboardHeader` + `Breadcrumb` import shape against `src/app/(app)/settings/team/page.tsx` and match it exactly (component API may differ).

---

## Task 8: Integrations client island

**Files:**
- Create: `src/components/gsc-integrations.tsx`

- [ ] **Step 1: Write the client component**

`src/components/gsc-integrations.tsx`:
```tsx
// src/components/gsc-integrations.tsx
"use client";

import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import {
  type GscStatus,
  setSiteGscPropertyAction,
} from "@/lib/gsc/actions";

const WEBMASTERS_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
const UNMAPPED = "__none__";

export function GscIntegrations({ initial }: { initial: GscStatus }) {
  const [status] = useState(initial);
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  async function connect() {
    setError(null);
    await authClient.linkSocial({
      provider: "google",
      scopes: [WEBMASTERS_SCOPE],
      callbackURL: "/settings/integrations",
    });
  }

  function setProperty(siteId: string, value: string) {
    setError(null);
    const property = value === UNMAPPED ? null : value;
    startTransition(async () => {
      const res = await setSiteGscPropertyAction(siteId, property);
      if (!res.ok) setError(res.error);
      else window.location.reload();
    });
  }

  return (
    <div className="flex max-w-2xl flex-col gap-6">
      <Card>
        <CardHeader>
          <CardTitle>Google Search Console</CardTitle>
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          {error ? (
            <p className="text-destructive text-sm" role="alert">
              {error}
            </p>
          ) : null}

          {!status.canManage ? (
            <p className="text-muted-foreground text-sm">
              {status.connected
                ? "Search Console est connecté par le propriétaire de l'organisation."
                : "Le propriétaire n'a pas encore connecté Search Console."}
            </p>
          ) : status.connected ? (
            <p className="text-muted-foreground text-sm">
              Connecté à Google Search Console ✓
            </p>
          ) : (
            <div className="flex flex-col items-start gap-2">
              <p className="text-muted-foreground text-sm">
                Connecte ton compte Google pour mapper tes propriétés Search
                Console.
              </p>
              <Button onClick={connect}>
                Connecter Google Search Console
              </Button>
            </div>
          )}
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>Propriétés par site</CardTitle>
        </CardHeader>
        <CardContent className="flex flex-col gap-3">
          {status.sites.length === 0 ? (
            <p className="text-muted-foreground text-sm">Aucun site.</p>
          ) : (
            status.sites.map((site) => (
              <div
                key={site.id}
                className="flex items-center justify-between gap-4"
              >
                <span className="truncate text-sm">{site.name}</span>
                {status.canManage && status.connected ? (
                  <Select
                    defaultValue={site.gscProperty ?? UNMAPPED}
                    onValueChange={(v) => setProperty(site.id, v)}
                    disabled={pending}
                  >
                    <SelectTrigger className="w-[280px]">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent position="popper">
                      <SelectItem value={UNMAPPED}>Non mappé</SelectItem>
                      {status.properties.map((p) => (
                        <SelectItem key={p.siteUrl} value={p.siteUrl}>
                          {p.siteUrl}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                ) : (
                  <span className="text-muted-foreground text-sm">
                    {site.gscProperty ?? "Non mappé"}
                  </span>
                )}
              </div>
            ))
          )}
        </CardContent>
      </Card>
    </div>
  );
}
```

- [ ] **Step 2: Verify Select + Card components exist**

Run: `ls src/components/ui/select.tsx src/components/ui/card.tsx`
Expected: both exist. If `select.tsx` is missing, add it via the shadcn skill before continuing. Confirm `CardHeader`/`CardTitle` are exported by `card.tsx` (grep); if not, use a plain heading instead.

- [ ] **Step 3: Verify `authClient.linkSocial` signature**

Grep the better-auth client types for `linkSocial`; confirm it accepts `{ provider, scopes?, callbackURL? }`. Adjust the call if the param names differ.

- [ ] **Step 4: Typecheck + lint**

Run:
```bash
bunx tsc --noEmit
bunx biome check --write "src/app/(app)/settings/integrations/page.tsx" src/components/gsc-integrations.tsx
bunx tsc --noEmit
```
Expected: exit 0.

- [ ] **Step 5: Commit**

```bash
git add "src/app/(app)/settings/integrations" src/components/gsc-integrations.tsx
git commit -m "feat(gsc): Settings -> Intégrations page + client (connect + per-site mapping)"
```

---

## Task 9: Live verification

- [ ] **Step 1: Confirm pages compile + gate**

Run:
```bash
d3k agent-browser open http://localhost:3000/settings/integrations
d3k agent-browser get url   # → /login when no session (gated) — confirms compile + proxy gate
d3k errors -n 8             # no build/runtime errors
```

- [ ] **Step 2: Logged-in smoke (if a session is available)**

If you can sign in: as the org **owner**, open `/settings/integrations` → see "Connecter Google Search Console"; after connecting Google (real grant), the per-site Selects list verified properties; choosing one persists (reload shows it). As a **member**, the controls are read-only.

- [ ] **Step 3: Full suite green**

Run: `bunx tsc --noEmit` (exit 0) and `bunx biome check src/lib/gsc src/components/gsc-integrations.tsx "src/app/(app)/settings/integrations/page.tsx"` (clean).

- [ ] **Step 4: Final commit (if any fixups)**

```bash
git add -A
git commit -m "chore(gsc): verification fixups"
```

---

## Notes / risks for the implementer

- `getGscAccessToken` relies on `auth.api.getAccessToken` returning `{ accessToken }` for the current session's linked Google account. Verify the exact return shape and the property name; adjust `token.ts` if needed.
- `authClient.linkSocial` triggers a full Google OAuth redirect; the `webmasters.readonly` scope must be granted. The Google provider in `auth.ts` already requests it; linkSocial's `scopes` reinforces it.
- Cross-user token (member fetching with owner's token) is **out of scope** here and deferred to the performance-dashboard slice (see spec).
- Keep `sites.gscProperty` in the schema — a future `db:generate` must not drop it.
