Self-hosted Google Search Console (#236)
This commit is contained in:
parent
23c38ca4e5
commit
7a26be46cc
@ -37,3 +37,10 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
# Optional in self-hosted modes. Required if you want Google Search Console
|
||||||
|
# integration and MCP tools. BETTER_AUTH_SECRET is also required for GSC (it
|
||||||
|
# encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md.
|
||||||
|
# GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id
|
||||||
|
# GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret
|
||||||
|
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
|
||||||
|
|||||||
@ -29,6 +29,7 @@ Easy to self-host, fork and extend, but we have a managed version too:
|
|||||||
- [Community](#community)
|
- [Community](#community)
|
||||||
- [Pricing / Costs (Free + API costs)](#pricing--costs)
|
- [Pricing / Costs (Free + API costs)](#pricing--costs)
|
||||||
- [DataForSEO API Key Setup](#dataforseo-api-key-setup)
|
- [DataForSEO API Key Setup](#dataforseo-api-key-setup)
|
||||||
|
- [Google Search Console](#google-search-console)
|
||||||
- [Self-hosting](#self-hosting)
|
- [Self-hosting](#self-hosting)
|
||||||
- [Docker Self Hosting](#docker-self-hosting)
|
- [Docker Self Hosting](#docker-self-hosting)
|
||||||
- [Cloudflare Self-Hosting](#cloudflare-self-hosting)
|
- [Cloudflare Self-Hosting](#cloudflare-self-hosting)
|
||||||
@ -203,6 +204,12 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
|
|||||||
- Cloudflare: Set it in the workers UI
|
- Cloudflare: Set it in the workers UI
|
||||||
- Local development: `.env.local`
|
- Local development: `.env.local`
|
||||||
|
|
||||||
|
## Google Search Console
|
||||||
|
|
||||||
|
Search Console is optional and works in self-hosted deployments using your own
|
||||||
|
Google OAuth client. It takes ~10 minutes of one-time setup — see
|
||||||
|
[`docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md`](./docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md).
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
OpenSEO supports two self-hosting paths:
|
OpenSEO supports two self-hosting paths:
|
||||||
|
|||||||
@ -9,6 +9,11 @@ services:
|
|||||||
- ALLOWED_HOST=${ALLOWED_HOST:-}
|
- ALLOWED_HOST=${ALLOWED_HOST:-}
|
||||||
- AUTH_MODE=local_noauth
|
- AUTH_MODE=local_noauth
|
||||||
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
||||||
|
# Optional: Google Search Console. See
|
||||||
|
# docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-}
|
||||||
- VITE_SHOW_DEVTOOLS=false
|
- VITE_SHOW_DEVTOOLS=false
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${PORT:-3001}:${PORT:-3001}"
|
- "127.0.0.1:${PORT:-3001}:${PORT:-3001}"
|
||||||
|
|||||||
118
docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
Normal file
118
docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Self-hosted Google Search Console
|
||||||
|
|
||||||
|
Connecting Google Search Console (GSC) lets OpenSEO pull your real clicks,
|
||||||
|
impressions, positions, and URL inspection data, straight from Google.
|
||||||
|
|
||||||
|
It's **optional**: OpenSEO runs fine without it, just without Search Console data.
|
||||||
|
|
||||||
|
## What you'll need
|
||||||
|
|
||||||
|
- A Google account with access to your verified Search Console property.
|
||||||
|
- ~10 minutes in the [Google Cloud Console](https://console.cloud.google.com/).
|
||||||
|
- Three environment variables set on your deployment (see [step 4](#4-set-environment-variables)).
|
||||||
|
|
||||||
|
## 1) Create a Google Cloud project and enable the API
|
||||||
|
|
||||||
|
1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create
|
||||||
|
a project (or pick an existing one).
|
||||||
|
2. Enable the
|
||||||
|
[Google Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
|
||||||
|
for that project.
|
||||||
|
|
||||||
|
## 2) Configure the OAuth consent screen
|
||||||
|
|
||||||
|
Under **APIs & Services → OAuth consent screen**:
|
||||||
|
|
||||||
|
- Pick **External** (unless everyone using it is in your Google Workspace org).
|
||||||
|
- Fill in the app name, support email, and developer contact email.
|
||||||
|
- While the app is in **Testing**, add the Google accounts that will connect as
|
||||||
|
**test users** — otherwise Google blocks the sign-in with `access_denied`.
|
||||||
|
|
||||||
|
For personal or internal use you don't need to submit for verification; testing
|
||||||
|
mode is enough.
|
||||||
|
|
||||||
|
## 3) Create an OAuth client ID
|
||||||
|
|
||||||
|
Under **APIs & Services → Credentials → Create credentials → OAuth client ID**:
|
||||||
|
|
||||||
|
1. Application type: **Web application**.
|
||||||
|
2. Add an **Authorized redirect URI** that exactly matches your deployment's
|
||||||
|
origin plus `/api/gsc/oauth/callback`:
|
||||||
|
|
||||||
|
| Deployment | Redirect URI |
|
||||||
|
| ------------ | -------------------------------------------------------- |
|
||||||
|
| Deployed | `https://your-openseo-domain.com/api/gsc/oauth/callback` |
|
||||||
|
| Local Docker | `http://localhost:3001/api/gsc/oauth/callback` |
|
||||||
|
|
||||||
|
The scheme, host, and port must match exactly, with no trailing slash.
|
||||||
|
|
||||||
|
3. Save, then copy the **Client ID** and **Client secret**.
|
||||||
|
|
||||||
|
## 4) Set environment variables
|
||||||
|
|
||||||
|
Set these three values, then restart OpenSEO:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `GOOGLE_CLIENT_ID` | Client ID from step 3. |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | Client secret from step 3. |
|
||||||
|
| `BETTER_AUTH_SECRET` | A random string of **at least 32 characters** (encrypts stored tokens). |
|
||||||
|
|
||||||
|
`BETTER_AUTH_SECRET` is not needed for normal self-hosting — only for Search
|
||||||
|
Console, because the stored OAuth tokens are encrypted at rest with it. Generate
|
||||||
|
one with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Where to set them:
|
||||||
|
|
||||||
|
- **Docker self-hosting:** `.env`
|
||||||
|
- **Cloudflare:** the Workers dashboard (as secrets)
|
||||||
|
- **Local development:** `.env.local`
|
||||||
|
|
||||||
|
## 5) Restart and connect
|
||||||
|
|
||||||
|
Restart OpenSEO so it picks up the new variables. For Docker, changing `.env`
|
||||||
|
means Compose has to recreate the container to reapply it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate open-seo
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **Integrations**, click **Connect with Google**, authorize the Google
|
||||||
|
account that owns your verified property, and pick the property to bind to your
|
||||||
|
project.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- OpenSEO uses your Google client to run the OAuth flow and stores the resulting
|
||||||
|
grant in its database, with the access and refresh tokens **encrypted at rest**
|
||||||
|
(keyed by `BETTER_AUTH_SECRET`).
|
||||||
|
- Access tokens are minted and refreshed on demand — you only authorize once.
|
||||||
|
- Search Console data comes from your own Google account, so OpenSEO never meters credits for it.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`redirect_uri_mismatch` from Google** — the redirect URI in your OAuth client
|
||||||
|
must exactly equal `<your-origin>/api/gsc/oauth/callback`. Re-check scheme
|
||||||
|
(`http` vs `https`), host, port, and that there's no trailing slash.
|
||||||
|
|
||||||
|
**"Google OAuth client not configured" / "not configured for Search Console yet"**
|
||||||
|
(in the app or via the MCP tools) — one of `GOOGLE_CLIENT_ID`,
|
||||||
|
`GOOGLE_CLIENT_SECRET`, or `BETTER_AUTH_SECRET` is missing, or the secret is
|
||||||
|
shorter than 32 characters. Set all three and restart. On Docker, recreate the
|
||||||
|
container so Compose reapplies `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate open-seo
|
||||||
|
```
|
||||||
|
|
||||||
|
**`access_denied` during sign-in** — the Google account isn't listed as a test
|
||||||
|
user on the OAuth consent screen (while the app is in Testing mode). Add it under
|
||||||
|
**OAuth consent screen → Test users**.
|
||||||
|
|
||||||
|
**Connected, but no properties to pick** — the Google account you authorized
|
||||||
|
doesn't have a verified property in Search Console. Verify the site in
|
||||||
|
[Search Console](https://search.google.com/search-console) first, then reconnect.
|
||||||
11
drizzle/0020_drop_delegated_users.sql
Normal file
11
drizzle/0020_drop_delegated_users.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
INSERT OR IGNORE INTO `user` (`id`, `name`, `email`, `email_verified`, `created_at`, `updated_at`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
coalesce(nullif(substr(`email`, 1, instr(`email`, '@') - 1), ''), `email`),
|
||||||
|
`email`,
|
||||||
|
1,
|
||||||
|
cast(unixepoch(`created_at`) * 1000 as integer),
|
||||||
|
cast(unixepoch(`created_at`) * 1000 as integer)
|
||||||
|
FROM `delegated_users`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `delegated_users`;
|
||||||
2528
drizzle/meta/0020_snapshot.json
Normal file
2528
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -141,6 +141,13 @@
|
|||||||
"when": 1780519331717,
|
"when": 1780519331717,
|
||||||
"tag": "0019_true_absorbing_man",
|
"tag": "0019_true_absorbing_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1780599087400,
|
||||||
|
"tag": "0020_drop_delegated_users",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -11,9 +11,10 @@ import { dismissGscNudge } from "@/serverFunctions/onboarding";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time re-engagement prompt nudging users who finished onboarding *before*
|
* One-time re-engagement prompt nudging users who finished onboarding *before*
|
||||||
* the Search Console step existed to connect GSC. Hosted-only (the connect flow
|
* the Search Console step existed to connect GSC. Hosted-only because this is
|
||||||
* needs Better Auth). Shows once — server-persisted dismissal means it never
|
* a hosted onboarding re-engagement nudge. Shows once — server-persisted
|
||||||
* reappears after the user connects or dismisses, on any device.
|
* dismissal means it never reappears after the user connects or dismisses, on
|
||||||
|
* any device.
|
||||||
*
|
*
|
||||||
* `suppressed` lets the layout hide this when another modal (e.g. the missing
|
* `suppressed` lets the layout hide this when another modal (e.g. the missing
|
||||||
* DataForSEO key prompt) is already showing so the two never stack.
|
* DataForSEO key prompt) is already showing so the two never stack.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|||||||
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";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||||
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
import {
|
import {
|
||||||
@ -30,16 +31,17 @@ export function SearchConsoleConnectionCard({
|
|||||||
const connectionQuery = useQuery({
|
const connectionQuery = useQuery({
|
||||||
queryKey: connectionKey,
|
queryKey: connectionKey,
|
||||||
queryFn: () => getGscConnection({ data: { projectId } }),
|
queryFn: () => getGscConnection({ data: { projectId } }),
|
||||||
enabled: hosted,
|
|
||||||
});
|
});
|
||||||
const connection = connectionQuery.data;
|
const connection = connectionQuery.data;
|
||||||
const connected = Boolean(connection?.connected);
|
const connected = Boolean(connection?.connected);
|
||||||
|
const selfHostedNeedsSetup =
|
||||||
|
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
||||||
|
|
||||||
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
||||||
const sitesQuery = useQuery({
|
const sitesQuery = useQuery({
|
||||||
queryKey: ["gscSites", projectId],
|
queryKey: ["gscSites", projectId],
|
||||||
queryFn: () => listGscSites({ data: { projectId } }),
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
enabled: Boolean(showPicker),
|
enabled: Boolean(showPicker && !selfHostedNeedsSetup),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setSiteMutation = useMutation({
|
const setSiteMutation = useMutation({
|
||||||
@ -70,24 +72,16 @@ export function SearchConsoleConnectionCard({
|
|||||||
|
|
||||||
const handleConnect = () => void startGscLink(window.location.href);
|
const handleConnect = () => void startGscLink(window.location.href);
|
||||||
|
|
||||||
if (!hosted) {
|
|
||||||
return (
|
|
||||||
<IntegrationCard>
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
|
||||||
</p>
|
|
||||||
</IntegrationCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationCard
|
<IntegrationCard
|
||||||
status={
|
status={
|
||||||
connectionQuery.isLoading
|
connectionQuery.isLoading
|
||||||
? undefined
|
? undefined
|
||||||
: connected
|
: selfHostedNeedsSetup
|
||||||
? "connected"
|
? "setup_required"
|
||||||
: "disconnected"
|
: connected
|
||||||
|
? "connected"
|
||||||
|
: "disconnected"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{connectionQuery.isLoading ? (
|
{connectionQuery.isLoading ? (
|
||||||
@ -95,6 +89,8 @@ export function SearchConsoleConnectionCard({
|
|||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
Checking…
|
Checking…
|
||||||
</div>
|
</div>
|
||||||
|
) : selfHostedNeedsSetup ? (
|
||||||
|
<SelfHostedSetupWarning />
|
||||||
) : connected && !picking ? (
|
) : connected && !picking ? (
|
||||||
<ConnectedState
|
<ConnectedState
|
||||||
siteUrl={connection?.siteUrl ?? ""}
|
siteUrl={connection?.siteUrl ?? ""}
|
||||||
@ -156,7 +152,7 @@ function IntegrationCard({
|
|||||||
status,
|
status,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
status?: "connected" | "disconnected";
|
status?: "connected" | "disconnected" | "setup_required";
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -177,24 +173,39 @@ function IntegrationCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ status }: { status: "connected" | "disconnected" }) {
|
function StatusPill({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: "connected" | "disconnected" | "setup_required";
|
||||||
|
}) {
|
||||||
const connected = status === "connected";
|
const connected = status === "connected";
|
||||||
|
const setupRequired = status === "setup_required";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||||
connected
|
connected
|
||||||
? "border-success/30 bg-success/10 text-success"
|
? "border-success/30 bg-success/10 text-success"
|
||||||
: "border-base-300 bg-base-200 text-base-content/60",
|
: setupRequired
|
||||||
|
? "border-warning/30 bg-warning/10 text-warning"
|
||||||
|
: "border-base-300 bg-base-200 text-base-content/60",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"size-1.5 rounded-full",
|
"size-1.5 rounded-full",
|
||||||
connected ? "bg-success" : "bg-base-content/40",
|
connected
|
||||||
|
? "bg-success"
|
||||||
|
: setupRequired
|
||||||
|
? "bg-warning"
|
||||||
|
: "bg-base-content/40",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
{connected ? "Connected" : "Not connected"}
|
{connected
|
||||||
|
? "Connected"
|
||||||
|
: setupRequired
|
||||||
|
? "Setup required"
|
||||||
|
: "Not connected"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/client/features/gsc/SelfHostedSetupWarning.tsx
Normal file
27
src/client/features/gsc/SelfHostedSetupWarning.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||||
|
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown in self-hosted deployments that haven't set GOOGLE_CLIENT_ID/SECRET yet
|
||||||
|
* — in both the Integrations card and the onboarding step.
|
||||||
|
*/
|
||||||
|
export function SelfHostedSetupWarning() {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-warning items-start text-sm">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">Google OAuth client not configured</p>
|
||||||
|
<p className="text-base-content/70">
|
||||||
|
Add your Google client ID and secret to this OpenSEO deployment before
|
||||||
|
connecting Search Console.
|
||||||
|
</p>
|
||||||
|
<SafeExternalLink
|
||||||
|
url={GSC_SELF_HOSTED_SETUP_DOCS_URL}
|
||||||
|
label="Open setup guide"
|
||||||
|
className="inline-flex items-center gap-1 font-medium underline underline-offset-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { startSelfHostedGscLink } from "@/serverFunctions/gsc";
|
||||||
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -12,6 +14,12 @@ import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
|||||||
*/
|
*/
|
||||||
export async function startGscLink(callbackURL: string): Promise<void> {
|
export async function startGscLink(callbackURL: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (!isHostedClientAuthMode()) {
|
||||||
|
const res = await startSelfHostedGscLink({ data: { callbackURL } });
|
||||||
|
window.location.href = res.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await authClient.oauth2.link({
|
const res = await authClient.oauth2.link({
|
||||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Check } from "lucide-react";
|
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 { SitePicker } from "@/client/features/gsc/SitePicker";
|
import { SitePicker } 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";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|
||||||
import {
|
import {
|
||||||
getGscConnection,
|
getGscConnection,
|
||||||
listGscSites,
|
listGscSites,
|
||||||
@ -19,14 +19,12 @@ import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
|||||||
* 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 default project —
|
* OAuth grant, then bind a verified property to the user's default project —
|
||||||
* the same binding the project's Integrations page does — so it's done in one
|
* the same binding the project's Integrations page does — so it's done in one
|
||||||
* place. Hosted-only (the connect flow needs Better Auth).
|
* place.
|
||||||
*/
|
*/
|
||||||
export function SearchConsoleOnboardingStep() {
|
export function SearchConsoleOnboardingStep() {
|
||||||
const hosted = isHostedClientAuthMode();
|
|
||||||
const projectQuery = useQuery({
|
const projectQuery = useQuery({
|
||||||
queryKey: ["defaultProject"],
|
queryKey: ["defaultProject"],
|
||||||
queryFn: () => getOrCreateDefaultProject(),
|
queryFn: () => getOrCreateDefaultProject(),
|
||||||
enabled: hosted,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -35,11 +33,7 @@ export function SearchConsoleOnboardingStep() {
|
|||||||
Connect with Google Search Console now?
|
Connect with Google Search Console now?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{!hosted ? (
|
{projectQuery.data ? (
|
||||||
<p className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5 text-sm text-base-content/60">
|
|
||||||
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
|
||||||
</p>
|
|
||||||
) : projectQuery.data ? (
|
|
||||||
<GscConnect projectId={projectQuery.data.id} />
|
<GscConnect projectId={projectQuery.data.id} />
|
||||||
) : (
|
) : (
|
||||||
<Checking />
|
<Checking />
|
||||||
@ -66,11 +60,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
const connection = connectionQuery.data;
|
const connection = connectionQuery.data;
|
||||||
const connected = Boolean(connection?.connected);
|
const connected = Boolean(connection?.connected);
|
||||||
const hasGrant = Boolean(connection?.currentUserHasGrant);
|
const hasGrant = Boolean(connection?.currentUserHasGrant);
|
||||||
|
const needsSetup =
|
||||||
|
connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
||||||
|
|
||||||
const sitesQuery = useQuery({
|
const sitesQuery = useQuery({
|
||||||
queryKey: ["gscSites", projectId],
|
queryKey: ["gscSites", projectId],
|
||||||
queryFn: () => listGscSites({ data: { projectId } }),
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
enabled: hasGrant && !connected,
|
enabled: hasGrant && !connected && !needsSetup,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setSiteMutation = useMutation({
|
const setSiteMutation = useMutation({
|
||||||
@ -90,6 +86,10 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
if (connectionQuery.isLoading) return <Checking />;
|
if (connectionQuery.isLoading) return <Checking />;
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return <SelfHostedSetupWarning />;
|
||||||
|
}
|
||||||
|
|
||||||
if (connected) {
|
if (connected) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">
|
<div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">
|
||||||
|
|||||||
@ -9,16 +9,6 @@ import {
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { organization, user } from "./better-auth-schema";
|
import { organization, user } from "./better-auth-schema";
|
||||||
|
|
||||||
// This stores users for Cloudflare Access and local_noauth mode
|
|
||||||
// since they don't map to better-auth's user schema
|
|
||||||
export const delegatedUsers = sqliteTable("delegated_users", {
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
createdAt: text("created_at")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(current_timestamp)`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const userOnboardingAnswers = sqliteTable(
|
export const userOnboardingAnswers = sqliteTable(
|
||||||
"user_onboarding_answers",
|
"user_onboarding_answers",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
|
|||||||
import { organization } from "./better-auth-schema";
|
import { organization } from "./better-auth-schema";
|
||||||
import { projects } from "./app.schema";
|
import { projects } from "./app.schema";
|
||||||
|
|
||||||
// Connected Google Search Console property per project (hosted-only).
|
// Connected Google Search Console property per project.
|
||||||
// OAuth tokens live in the better-auth `account` table under providerId
|
// OAuth tokens live in the better-auth `account` table under providerId
|
||||||
// "google-search-console"; this row only records which verified property maps
|
// "google-search-console"; this row only records which verified property maps
|
||||||
// to a project and whose grant to use when calling the GSC API.
|
// to a project and whose grant to use when calling the GSC API.
|
||||||
|
|||||||
@ -1,16 +1,7 @@
|
|||||||
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 { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
||||||
|
|
||||||
/** Read-only Search Console scope. openid/email/profile are also required —
|
|
||||||
* the genericOAuth callback rejects with `name_is_missing` without a name claim. */
|
|
||||||
const GSC_OAUTH_SCOPES = [
|
|
||||||
"openid",
|
|
||||||
"email",
|
|
||||||
"profile",
|
|
||||||
"https://www.googleapis.com/auth/webmasters.readonly",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createBaseAuthConfig() {
|
export function createBaseAuthConfig() {
|
||||||
return {
|
return {
|
||||||
@ -35,7 +26,7 @@ export function createBaseAuthConfig() {
|
|||||||
clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
|
clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
|
||||||
discoveryUrl:
|
discoveryUrl:
|
||||||
"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: "consent", // force refresh-token issuance on re-consent
|
||||||
pkce: true,
|
pkce: true,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
import {
|
import {
|
||||||
@ -24,7 +25,12 @@ const hostedBaseUrlSchema = z
|
|||||||
}, "BETTER_AUTH_URL must use https or localhost");
|
}, "BETTER_AUTH_URL must use https or localhost");
|
||||||
|
|
||||||
function createAuth() {
|
function createAuth() {
|
||||||
const baseUrl = getHostedBaseUrl();
|
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
|
||||||
|
// all use it). Self-hosted only builds this instance to mint/refresh Search
|
||||||
|
// Console tokens, which never read baseURL — so a placeholder is fine there.
|
||||||
|
const baseUrl = isHostedAuthMode(env.AUTH_MODE)
|
||||||
|
? getHostedBaseUrl()
|
||||||
|
: "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();
|
||||||
|
|
||||||
@ -142,11 +148,14 @@ export function getHostedBaseUrl() {
|
|||||||
return hostedBaseUrlSchema.parse(baseUrl);
|
return hostedBaseUrlSchema.parse(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required in hosted mode, and in self-hosted mode when Search Console is
|
||||||
|
// enabled (it keys the OAuth-token encryption and is needed to build the auth
|
||||||
|
// instance that mints/refreshes Search Console tokens).
|
||||||
function getHostedSecret() {
|
function getHostedSecret() {
|
||||||
const secret = env.BETTER_AUTH_SECRET?.trim();
|
const secret = env.BETTER_AUTH_SECRET?.trim();
|
||||||
|
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("BETTER_AUTH_SECRET is required in hosted mode");
|
throw new Error("BETTER_AUTH_SECRET is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secret.length < 32) {
|
if (secret.length < 32) {
|
||||||
@ -157,6 +166,15 @@ function getHostedSecret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSocialProviders() {
|
function getSocialProviders() {
|
||||||
|
// Google social login is hosted-only. Self-hosted builds the auth instance
|
||||||
|
// solely for Search Console token ops, which use the genericOAuth provider
|
||||||
|
// (createBaseAuthConfig) with its own creds — so it must NOT require the
|
||||||
|
// social-login config here, otherwise getAuth() construction would be coupled
|
||||||
|
// to GSC creds rather than just BETTER_AUTH_SECRET.
|
||||||
|
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
google: getGoogleSocialProviderConfig(),
|
google: getGoogleSocialProviderConfig(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { delegatedUsers } from "@/db/schema";
|
import { user } from "@/db/schema";
|
||||||
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
|
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { EnsuredUserContext } from "./types";
|
import type { EnsuredUserContext } from "./types";
|
||||||
@ -7,30 +7,49 @@ import type { EnsuredUserContext } from "./types";
|
|||||||
const LOCAL_ADMIN_USER_ID = "local-admin";
|
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||||
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||||
|
|
||||||
|
// Externally-authenticated users (Cloudflare Access, local_noauth) are stored
|
||||||
|
// in better-auth's `user` table just like hosted users — only the way we
|
||||||
|
// authenticate them differs (per-request, no better-auth session). Keeping a
|
||||||
|
// single user table means the OAuth `account` grant and every app table that
|
||||||
|
// references `user.id` resolve the same way in all auth modes.
|
||||||
|
function deriveUserName(email: string) {
|
||||||
|
return email.split("@")[0] || "OpenSEO";
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureUserRecord(userId: string, userEmail: string) {
|
async function ensureUserRecord(userId: string, userEmail: string) {
|
||||||
const existingUser = await db.query.delegatedUsers.findFirst({
|
const existing = await db.query.user.findFirst({
|
||||||
where: eq(delegatedUsers.id, userId),
|
columns: { email: true },
|
||||||
|
where: eq(user.id, userId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existing) {
|
||||||
await db.insert(delegatedUsers).values({
|
// Concurrent first-load requests can all see "no row" and race to insert
|
||||||
id: userId,
|
// the same id; onConflictDoNothing on the PK makes the losers no-ops instead
|
||||||
email: userEmail,
|
// of failing. Scoped to the id so a genuine email-unique collision (two
|
||||||
});
|
// distinct ids sharing an email) still surfaces loudly.
|
||||||
|
|
||||||
return userEmail;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUser.email !== userEmail) {
|
|
||||||
await db
|
await db
|
||||||
.update(delegatedUsers)
|
.insert(user)
|
||||||
.set({ email: userEmail })
|
.values({
|
||||||
.where(eq(delegatedUsers.id, userId));
|
id: userId,
|
||||||
|
name: deriveUserName(userEmail),
|
||||||
|
email: userEmail,
|
||||||
|
emailVerified: true,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: user.id });
|
||||||
|
|
||||||
return userEmail;
|
return userEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingUser.email;
|
if (existing.email !== userEmail) {
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ email: userEmail, name: deriveUserName(userEmail) })
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDelegatedContext(
|
export async function resolveDelegatedContext(
|
||||||
|
|||||||
@ -29,6 +29,7 @@ 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 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 ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
|
||||||
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'
|
||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
@ -144,6 +145,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
||||||
|
id: '/api/gsc/oauth/callback',
|
||||||
|
path: '/api/gsc/oauth/callback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
||||||
id: '/help/dataforseo-api-key',
|
id: '/help/dataforseo-api-key',
|
||||||
path: '/help/dataforseo-api-key',
|
path: '/help/dataforseo-api-key',
|
||||||
@ -254,6 +260,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -287,6 +294,7 @@ export interface FileRoutesByTo {
|
|||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
@ -324,6 +332,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -360,6 +369,7 @@ export interface FileRouteTypes {
|
|||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/gsc/oauth/callback'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
@ -393,6 +403,7 @@ export interface FileRouteTypes {
|
|||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/gsc/oauth/callback'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
@ -429,6 +440,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/help/dataforseo-api-key'
|
| '/_app/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/api/gsc/oauth/callback'
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
| '/_project/p/$projectId/brand-lookup'
|
| '/_project/p/$projectId/brand-lookup'
|
||||||
@ -456,6 +468,7 @@ export interface RootRouteChildren {
|
|||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
|
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@ -600,6 +613,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/gsc/oauth/callback': {
|
||||||
|
id: '/api/gsc/oauth/callback'
|
||||||
|
path: '/api/gsc/oauth/callback'
|
||||||
|
fullPath: '/api/gsc/oauth/callback'
|
||||||
|
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_app/help/dataforseo-api-key': {
|
'/_app/help/dataforseo-api-key': {
|
||||||
id: '/_app/help/dataforseo-api-key'
|
id: '/_app/help/dataforseo-api-key'
|
||||||
path: '/help/dataforseo-api-key'
|
path: '/help/dataforseo-api-key'
|
||||||
@ -857,6 +877,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
|
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
62
src/routes/api/gsc/oauth/callback.ts
Normal file
62
src/routes/api/gsc/oauth/callback.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||||
|
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
|
||||||
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
|
|
||||||
|
async function resolveSelfHostedContext(request: Request) {
|
||||||
|
const authMode = getAuthMode(env.AUTH_MODE);
|
||||||
|
|
||||||
|
if (isHostedAuthMode(authMode)) return null;
|
||||||
|
|
||||||
|
return authMode === "local_noauth"
|
||||||
|
? resolveLocalNoAuthContext()
|
||||||
|
: resolveCloudflareAccessContext(request.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseForError(error: unknown) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
const status =
|
||||||
|
error.code === "UNAUTHENTICATED"
|
||||||
|
? 401
|
||||||
|
: error.code === "FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: error.code === "VALIDATION_ERROR"
|
||||||
|
? 400
|
||||||
|
: 500;
|
||||||
|
return new Response(error.message, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Search Console OAuth failed", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallbackRequest(request: Request) {
|
||||||
|
try {
|
||||||
|
const context = await resolveSelfHostedContext(request);
|
||||||
|
if (!context) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
|
return await handleSelfHostedGscOAuthCallback({
|
||||||
|
request,
|
||||||
|
user: {
|
||||||
|
userId: context.userId,
|
||||||
|
userEmail: context.userEmail,
|
||||||
|
},
|
||||||
|
publicOrigin: getPublicOrigin(request),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return responseForError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/gsc/oauth/callback")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
return handleCallbackRequest(request);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
28
src/server/features/gsc/oauth-config.ts
Normal file
28
src/server/features/gsc/oauth-config.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||||
|
|
||||||
|
type GscOAuthClientConfig = {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGscOAuthClientConfig(): Promise<GscOAuthClientConfig | null> {
|
||||||
|
const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim();
|
||||||
|
const clientSecret = (
|
||||||
|
await getOptionalEnvValue("GOOGLE_CLIENT_SECRET")
|
||||||
|
)?.trim();
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
return { clientId, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-hosted Search Console needs the Google OAuth client AND BETTER_AUTH_SECRET
|
||||||
|
// (>=32 chars): the secret keys OAuth-token encryption and lets us build the
|
||||||
|
// Better Auth instance that mints/refreshes tokens. Both must be set before we
|
||||||
|
// surface the connect flow.
|
||||||
|
export async function hasSelfHostedGscConfig(): Promise<boolean> {
|
||||||
|
if (!(await getGscOAuthClientConfig())) return false;
|
||||||
|
|
||||||
|
const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
|
||||||
|
return Boolean(secret && secret.length >= 32);
|
||||||
|
}
|
||||||
334
src/server/features/gsc/selfHostedOAuth.ts
Normal file
334
src/server/features/gsc/selfHostedOAuth.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import { symmetricEncrypt } from "better-auth/crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { decodeJwt } from "jose";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { account } from "@/db/schema";
|
||||||
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { AppError } from "@/server/lib/errors";
|
||||||
|
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
||||||
|
import {
|
||||||
|
getGscOAuthClientConfig,
|
||||||
|
hasSelfHostedGscConfig,
|
||||||
|
} from "./oauth-config";
|
||||||
|
|
||||||
|
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
|
|
||||||
|
type SelfHostedGscUser = {
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauthStateSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
callbackPath: z.string().min(1),
|
||||||
|
exp: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleTokenResponseSchema = z.object({
|
||||||
|
access_token: z.string().min(1),
|
||||||
|
expires_in: z.number().optional(),
|
||||||
|
refresh_token: z.string().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
id_token: z.string().optional(),
|
||||||
|
token_type: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleIdTokenSchema = z.object({
|
||||||
|
sub: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GoogleTokenResponse = z.infer<typeof googleTokenResponseSchema>;
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes: Uint8Array) {
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
.replaceAll("+", "-")
|
||||||
|
.replaceAll("/", "_")
|
||||||
|
.replaceAll("=", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value: string) {
|
||||||
|
const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`;
|
||||||
|
const binary = atob(padded.replaceAll("-", "+").replaceAll("_", "/"));
|
||||||
|
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStateKey(clientSecret: string) {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(`openseo:gsc:${clientSecret}`),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signState(payload: string, clientSecret: string) {
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
"HMAC",
|
||||||
|
await getStateKey(clientSecret),
|
||||||
|
new TextEncoder().encode(payload),
|
||||||
|
);
|
||||||
|
return bytesToBase64Url(new Uint8Array(signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeCallbackPath(callbackURL: string, publicOrigin: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(callbackURL, publicOrigin);
|
||||||
|
if (url.origin !== publicOrigin) return "/";
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createState(input: {
|
||||||
|
clientSecret: string;
|
||||||
|
userId: string;
|
||||||
|
callbackURL: string;
|
||||||
|
publicOrigin: string;
|
||||||
|
}) {
|
||||||
|
const payload = bytesToBase64Url(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
userId: input.userId,
|
||||||
|
callbackPath: getSafeCallbackPath(
|
||||||
|
input.callbackURL,
|
||||||
|
input.publicOrigin,
|
||||||
|
),
|
||||||
|
exp: Date.now() + 10 * 60 * 1_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const signature = await signState(payload, input.clientSecret);
|
||||||
|
return `${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyState(state: string, clientSecret: string) {
|
||||||
|
const [payload, signature] = state.split(".");
|
||||||
|
if (!payload || !signature) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await crypto.subtle.verify(
|
||||||
|
"HMAC",
|
||||||
|
await getStateKey(clientSecret),
|
||||||
|
base64UrlToBytes(signature),
|
||||||
|
new TextEncoder().encode(payload),
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = oauthStateSchema.parse(
|
||||||
|
JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))),
|
||||||
|
);
|
||||||
|
if (parsed.exp < Date.now()) {
|
||||||
|
throw new AppError("VALIDATION_ERROR", "Expired Search Console state");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectUri(publicOrigin: string) {
|
||||||
|
return `${publicOrigin}/api/gsc/oauth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessTokenExpiresAt(tokens: GoogleTokenResponse) {
|
||||||
|
return new Date(Date.now() + (tokens.expires_in ?? 3600) * 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedScope(tokens: GoogleTokenResponse) {
|
||||||
|
return tokens.scope
|
||||||
|
? tokens.scope.trim().split(/\s+/).join(",")
|
||||||
|
: GSC_OAUTH_SCOPES.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGoogleAccountId(tokens: GoogleTokenResponse) {
|
||||||
|
if (!tokens.id_token) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"Google did not return an ID token for Search Console.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return googleIdTokenSchema.parse(decodeJwt(tokens.id_token)).sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertGrant(input: {
|
||||||
|
user: SelfHostedGscUser;
|
||||||
|
tokens: GoogleTokenResponse;
|
||||||
|
}) {
|
||||||
|
// Encrypt tokens at rest exactly the way Better Auth's setTokenUtil does
|
||||||
|
// (same key from BETTER_AUTH_SECRET, same crypto, same encryptOAuthTokens
|
||||||
|
// gate), so getAccessToken decrypts them on read — and so flipping the flag
|
||||||
|
// can never desync the write and read paths.
|
||||||
|
const ctx = await getAuth().$context;
|
||||||
|
const encrypt = (value: string) =>
|
||||||
|
ctx.options.account?.encryptOAuthTokens
|
||||||
|
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
|
||||||
|
: value;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: account.id, refreshToken: account.refreshToken })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, input.user.userId),
|
||||||
|
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const accountValues = {
|
||||||
|
accountId: getGoogleAccountId(input.tokens),
|
||||||
|
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||||
|
userId: input.user.userId,
|
||||||
|
accessToken: await encrypt(input.tokens.access_token),
|
||||||
|
// A fresh refresh token is encrypted here; an absent one falls back to the
|
||||||
|
// already-encrypted value stored on the existing grant.
|
||||||
|
refreshToken: input.tokens.refresh_token
|
||||||
|
? await encrypt(input.tokens.refresh_token)
|
||||||
|
: (existing[0]?.refreshToken ?? null),
|
||||||
|
idToken: input.tokens.id_token
|
||||||
|
? await encrypt(input.tokens.id_token)
|
||||||
|
: null,
|
||||||
|
accessTokenExpiresAt: accessTokenExpiresAt(input.tokens),
|
||||||
|
refreshTokenExpiresAt: null,
|
||||||
|
scope: storedScope(input.tokens),
|
||||||
|
password: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await db
|
||||||
|
.update(account)
|
||||||
|
.set({ ...accountValues, updatedAt: new Date() })
|
||||||
|
.where(eq(account.id, existing[0].id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(account).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...accountValues,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(input: {
|
||||||
|
code: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}) {
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: input.code,
|
||||||
|
client_id: input.clientId,
|
||||||
|
client_secret: input.clientSecret,
|
||||||
|
redirect_uri: input.redirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AppError(
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"Google rejected the Search Console authorization code.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return googleTokenResponseSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSelfHostedGscAuthorizationUrl(input: {
|
||||||
|
user: SelfHostedGscUser;
|
||||||
|
callbackURL: string;
|
||||||
|
publicOrigin: string;
|
||||||
|
}) {
|
||||||
|
const config = await getGscOAuthClientConfig();
|
||||||
|
if (!config || !(await hasSelfHostedGscConfig())) {
|
||||||
|
throw new AppError(
|
||||||
|
"AUTH_CONFIG_MISSING",
|
||||||
|
"Search Console is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = getRedirectUri(input.publicOrigin);
|
||||||
|
const state = await createState({
|
||||||
|
clientSecret: config.clientSecret,
|
||||||
|
userId: input.user.userId,
|
||||||
|
callbackURL: input.callbackURL,
|
||||||
|
publicOrigin: input.publicOrigin,
|
||||||
|
});
|
||||||
|
const url = new URL(GOOGLE_AUTH_URL);
|
||||||
|
url.searchParams.set("client_id", config.clientId);
|
||||||
|
url.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
|
||||||
|
url.searchParams.set("access_type", "offline");
|
||||||
|
url.searchParams.set("prompt", "consent");
|
||||||
|
url.searchParams.set("state", state);
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSelfHostedGscOAuthCallback(input: {
|
||||||
|
request: Request;
|
||||||
|
user: SelfHostedGscUser;
|
||||||
|
publicOrigin: string;
|
||||||
|
}) {
|
||||||
|
const config = await getGscOAuthClientConfig();
|
||||||
|
if (!config) {
|
||||||
|
return new Response("Missing Google Search Console OAuth configuration", {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(input.request.url);
|
||||||
|
const stateParam = url.searchParams.get("state");
|
||||||
|
if (!stateParam) {
|
||||||
|
return new Response("Missing Search Console OAuth state", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await verifyState(stateParam, config.clientSecret);
|
||||||
|
if (state.userId !== input.user.userId) {
|
||||||
|
return new Response("Search Console OAuth user mismatch", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// state.callbackPath is a validated same-origin relative path
|
||||||
|
// (getSafeCallbackPath). Redirect with a *relative* Location so the browser
|
||||||
|
// resolves it against the real request origin — this avoids trusting
|
||||||
|
// x-forwarded-host for the final hop.
|
||||||
|
const redirectToCallback = () =>
|
||||||
|
new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { Location: state.callbackPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.searchParams.get("error")) {
|
||||||
|
return redirectToCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
if (!code) {
|
||||||
|
return new Response("Missing Search Console OAuth code", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await exchangeCode({
|
||||||
|
code,
|
||||||
|
clientId: config.clientId,
|
||||||
|
clientSecret: config.clientSecret,
|
||||||
|
redirectUri: getRedirectUri(input.publicOrigin),
|
||||||
|
});
|
||||||
|
await upsertGrant({ user: input.user, tokens });
|
||||||
|
|
||||||
|
return redirectToCallback();
|
||||||
|
}
|
||||||
@ -103,8 +103,10 @@ export function createGscClient(opts: { userId: string }) {
|
|||||||
async function getToken(): Promise<string> {
|
async function getToken(): Promise<string> {
|
||||||
let result: { accessToken?: string } | undefined;
|
let result: { accessToken?: string } | undefined;
|
||||||
try {
|
try {
|
||||||
// Headerless call: getAccessToken trusts body.userId only when no request
|
// Headerless call: getAccessToken trusts body.userId when no request
|
||||||
// session is present, and auto-refreshes via the genericOAuth provider.
|
// session is present, and auto-refreshes via the genericOAuth provider.
|
||||||
|
// Works in every auth mode — self-hosted builds the same Better Auth
|
||||||
|
// 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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { isHostedAuthMode } from "@/lib/auth-mode";
|
|||||||
|
|
||||||
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
|
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
|
||||||
|
|
||||||
async function getEnvValue(name: string): Promise<string | undefined> {
|
export async function getOptionalEnvValue(
|
||||||
|
name: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const processValue =
|
const processValue =
|
||||||
typeof process !== "undefined" ? process.env?.[name] : undefined;
|
typeof process !== "undefined" ? process.env?.[name] : undefined;
|
||||||
if (processValue) {
|
if (processValue) {
|
||||||
@ -15,7 +17,7 @@ async function getEnvValue(name: string): Promise<string | undefined> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRequiredEnvValue(name: string): Promise<string> {
|
export async function getRequiredEnvValue(name: string): Promise<string> {
|
||||||
const value = await getEnvValue(name);
|
const value = await getOptionalEnvValue(name);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`Missing required environment variable: ${name}`);
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
}
|
}
|
||||||
@ -23,7 +25,7 @@ export async function getRequiredEnvValue(name: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isHostedServerAuthMode(): Promise<boolean> {
|
export async function isHostedServerAuthMode(): Promise<boolean> {
|
||||||
return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
|
return isHostedAuthMode(await getOptionalEnvValue("AUTH_MODE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
|
async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
getProjectForOrganization: vi.fn(),
|
getProjectForOrganization: vi.fn(),
|
||||||
isHostedServerAuthMode: vi.fn(),
|
isHostedServerAuthMode: vi.fn(),
|
||||||
|
hasSelfHostedGscConfig: vi.fn(),
|
||||||
GscService: {
|
GscService: {
|
||||||
getPerformance: vi.fn(),
|
getPerformance: vi.fn(),
|
||||||
inspectUrls: vi.fn(),
|
inspectUrls: vi.fn(),
|
||||||
@ -33,6 +34,9 @@ vi.mock("cloudflare:workers", () => ({ env: {} }));
|
|||||||
vi.mock("@/server/lib/runtime-env", () => ({
|
vi.mock("@/server/lib/runtime-env", () => ({
|
||||||
isHostedServerAuthMode: mocks.isHostedServerAuthMode,
|
isHostedServerAuthMode: mocks.isHostedServerAuthMode,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/server/features/gsc/oauth-config", () => ({
|
||||||
|
hasSelfHostedGscConfig: mocks.hasSelfHostedGscConfig,
|
||||||
|
}));
|
||||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
ProjectService: {
|
ProjectService: {
|
||||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
@ -75,6 +79,8 @@ describe("search console MCP tools", () => {
|
|||||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
||||||
mocks.isHostedServerAuthMode.mockReset();
|
mocks.isHostedServerAuthMode.mockReset();
|
||||||
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
||||||
|
mocks.hasSelfHostedGscConfig.mockReset();
|
||||||
|
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||||
mocks.GscService.getPerformance.mockReset();
|
mocks.GscService.getPerformance.mockReset();
|
||||||
mocks.GscService.inspectUrls.mockReset();
|
mocks.GscService.inspectUrls.mockReset();
|
||||||
});
|
});
|
||||||
@ -211,8 +217,9 @@ describe("search console MCP tools", () => {
|
|||||||
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a hosted-only message in self-hosted mode", async () => {
|
it("returns a setup message in self-hosted mode without a Google client", async () => {
|
||||||
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||||
|
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||||
const { getSearchConsolePerformanceTool } =
|
const { getSearchConsolePerformanceTool } =
|
||||||
await import("./search-console-tools");
|
await import("./search-console-tools");
|
||||||
|
|
||||||
@ -221,10 +228,40 @@ describe("search console MCP tools", () => {
|
|||||||
toolExtra,
|
toolExtra,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
reason: "gsc_oauth_not_configured",
|
||||||
|
});
|
||||||
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows performance queries in self-hosted mode with a Google client", async () => {
|
||||||
|
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||||
|
mocks.hasSelfHostedGscConfig.mockResolvedValue(true);
|
||||||
|
mocks.GscService.getPerformance.mockResolvedValue({
|
||||||
|
siteUrl: "https://example.com/",
|
||||||
|
connectedBy: "alice@example.com",
|
||||||
|
request: {
|
||||||
|
dimensions: ["query"],
|
||||||
|
startDate: "2026-04-27",
|
||||||
|
endDate: "2026-05-25",
|
||||||
|
rowLimit: 1000,
|
||||||
|
},
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
const { getSearchConsolePerformanceTool } =
|
||||||
|
await import("./search-console-tools");
|
||||||
|
|
||||||
|
const result = await getSearchConsolePerformanceTool.handler(
|
||||||
|
{ projectId: "project_1" },
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ projectId: "project_1" }),
|
||||||
|
);
|
||||||
|
expect(result.structuredContent).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("inspects multiple URLs and reports partial failures inline", async () => {
|
it("inspects multiple URLs and reports partial failures inline", async () => {
|
||||||
mocks.GscService.inspectUrls.mockResolvedValue({
|
mocks.GscService.inspectUrls.mockResolvedValue({
|
||||||
siteUrl: "sc-domain:example.com",
|
siteUrl: "sc-domain:example.com",
|
||||||
@ -285,8 +322,9 @@ describe("search console MCP tools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a hosted-only message for inspect_urls in self-hosted mode", async () => {
|
it("returns a setup message for inspect_urls in self-hosted mode without a Google client", async () => {
|
||||||
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||||
|
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||||
const { inspectUrlsTool } = await import("./search-console-tools");
|
const { inspectUrlsTool } = await import("./search-console-tools");
|
||||||
|
|
||||||
const result = await inspectUrlsTool.handler(
|
const result = await inspectUrlsTool.handler(
|
||||||
@ -294,7 +332,9 @@ describe("search console MCP tools", () => {
|
|||||||
toolExtra,
|
toolExtra,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
reason: "gsc_oauth_not_configured",
|
||||||
|
});
|
||||||
expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled();
|
expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { optionalMetaOutputSchema } 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 { buildDashboardUrl } from "@/server/mcp/urls";
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
|
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import {
|
import {
|
||||||
GscNotConnectedError,
|
GscNotConnectedError,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
type GscPerformanceInput,
|
type GscPerformanceInput,
|
||||||
} from "@/server/features/gsc/searchAnalytics";
|
} from "@/server/features/gsc/searchAnalytics";
|
||||||
import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
|
import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
|
||||||
|
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
|
||||||
|
|
||||||
const TEXT_SUMMARY_ROWS = 15;
|
const TEXT_SUMMARY_ROWS = 15;
|
||||||
|
|
||||||
@ -33,17 +35,28 @@ function integrationsUrl(baseUrl: string, projectId: string): string {
|
|||||||
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GSC connect requires Better Auth, which only runs in hosted mode. In
|
/** Self-hosted GSC requires the operator to provide a Google OAuth client and
|
||||||
* self-hosted deployments the tools return this instead of a broken flow. */
|
* BETTER_AUTH_SECRET. Hosted mode always has both; self-hosted tools return this
|
||||||
async function hostedOnlyResponse(
|
* setup nudge before attempting a token lookup when either is missing. */
|
||||||
|
async function missingSelfHostedGoogleClientResponse(
|
||||||
context: ProjectAuthContext,
|
context: ProjectAuthContext,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
) {
|
) {
|
||||||
if (await isHostedServerAuthMode()) return null;
|
const [hosted, configured] = await Promise.all([
|
||||||
|
isHostedServerAuthMode(),
|
||||||
|
hasSelfHostedGscConfig(),
|
||||||
|
]);
|
||||||
|
if (hosted || configured) return null;
|
||||||
|
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text: "Google Search Console connect is only available on the hosted OpenSEO service, not in self-hosted mode. Use a GSC CSV export instead.",
|
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
||||||
meta: buildProjectMeta(context, projectId),
|
meta: buildProjectMeta(context, projectId),
|
||||||
structuredContent: { ok: false, connected: false, reason: "hosted_only" },
|
structuredContent: {
|
||||||
|
ok: false,
|
||||||
|
connected: false,
|
||||||
|
reason: "gsc_oauth_not_configured",
|
||||||
|
setupDocsUrl: GSC_SELF_HOSTED_SETUP_DOCS_URL,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +160,7 @@ export const getSearchConsolePerformanceTool = {
|
|||||||
ok: z.boolean(),
|
ok: z.boolean(),
|
||||||
reason: z.string().optional(),
|
reason: z.string().optional(),
|
||||||
connectUrl: z.string().optional(),
|
connectUrl: z.string().optional(),
|
||||||
|
setupDocsUrl: z.string().optional(),
|
||||||
siteUrl: z.string().optional(),
|
siteUrl: z.string().optional(),
|
||||||
startDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: z.string().optional(),
|
||||||
@ -176,7 +190,10 @@ export const getSearchConsolePerformanceTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: PerfArgs, context) => {
|
handler: withMcpProjectAuth(async (args: PerfArgs, context) => {
|
||||||
const blocked = await hostedOnlyResponse(context, args.projectId);
|
const blocked = await missingSelfHostedGoogleClientResponse(
|
||||||
|
context,
|
||||||
|
args.projectId,
|
||||||
|
);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
|
|
||||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||||
@ -292,6 +309,7 @@ export const inspectUrlsTool = {
|
|||||||
ok: z.boolean(),
|
ok: z.boolean(),
|
||||||
reason: z.string().optional(),
|
reason: z.string().optional(),
|
||||||
connectUrl: z.string().optional(),
|
connectUrl: z.string().optional(),
|
||||||
|
setupDocsUrl: z.string().optional(),
|
||||||
siteUrl: z.string().optional(),
|
siteUrl: z.string().optional(),
|
||||||
results: z
|
results: z
|
||||||
.array(
|
.array(
|
||||||
@ -313,7 +331,10 @@ export const inspectUrlsTool = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: InspectArgs, context) => {
|
handler: withMcpProjectAuth(async (args: InspectArgs, context) => {
|
||||||
const blocked = await hostedOnlyResponse(context, args.projectId);
|
const blocked = await missingSelfHostedGoogleClientResponse(
|
||||||
|
context,
|
||||||
|
args.projectId,
|
||||||
|
);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
|
|
||||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { waitUntil } from "cloudflare:workers";
|
import { waitUntil } from "cloudflare:workers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GscService } from "@/server/features/gsc/services/GscService";
|
import { GscService } from "@/server/features/gsc/services/GscService";
|
||||||
|
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
|
||||||
|
import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth";
|
||||||
import { captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerEvent } from "@/server/lib/posthog";
|
||||||
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import {
|
import {
|
||||||
requireAuthenticatedContext,
|
requireAuthenticatedContext,
|
||||||
requireProjectContext,
|
requireProjectContext,
|
||||||
@ -12,6 +17,9 @@ const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
|||||||
const setSiteSchema = projectScopedSchema.extend({
|
const setSiteSchema = projectScopedSchema.extend({
|
||||||
siteUrl: z.string().min(1),
|
siteUrl: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const startSelfHostedLinkSchema = z.object({
|
||||||
|
callbackURL: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
// Account-level grant check (no project needed) for surfaces like onboarding
|
// Account-level grant check (no project needed) for surfaces like onboarding
|
||||||
// where the user hasn't picked a project yet. The OAuth grant is per-account;
|
// where the user hasn't picked a project yet. The OAuth grant is per-account;
|
||||||
@ -26,13 +34,17 @@ export const getGscConnection = createServerFn({ method: "POST" })
|
|||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
const [connection, currentUserHasGrant] = await Promise.all([
|
const [connection, currentUserHasGrant, hosted, gscConfigured] =
|
||||||
GscService.getConnection(context.projectId),
|
await Promise.all([
|
||||||
GscService.userHasGrant(context.userId),
|
GscService.getConnection(context.projectId),
|
||||||
]);
|
GscService.userHasGrant(context.userId),
|
||||||
|
isHostedServerAuthMode(),
|
||||||
|
hasSelfHostedGscConfig(),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
connected: Boolean(connection),
|
connected: Boolean(connection),
|
||||||
currentUserHasGrant,
|
currentUserHasGrant,
|
||||||
|
googleOAuthConfigured: hosted || gscConfigured,
|
||||||
siteUrl: connection?.siteUrl ?? null,
|
siteUrl: connection?.siteUrl ?? null,
|
||||||
connectedByEmail: connection?.connectedAccountEmail ?? null,
|
connectedByEmail: connection?.connectedAccountEmail ?? null,
|
||||||
connectedAt: connection?.createdAt ?? null,
|
connectedAt: connection?.createdAt ?? null,
|
||||||
@ -97,3 +109,20 @@ export const disconnectGsc = createServerFn({ method: "POST" })
|
|||||||
);
|
);
|
||||||
return { connected: false as const };
|
return { connected: false as const };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const startSelfHostedGscLink = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireAuthenticatedContext)
|
||||||
|
.inputValidator((data: unknown) => startSelfHostedLinkSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
const publicOrigin = getPublicOrigin(getRequest());
|
||||||
|
const url = await createSelfHostedGscAuthorizationUrl({
|
||||||
|
user: {
|
||||||
|
userId: context.userId,
|
||||||
|
userEmail: context.userEmail,
|
||||||
|
},
|
||||||
|
callbackURL: data.callbackURL,
|
||||||
|
publicOrigin,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url };
|
||||||
|
});
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
|||||||
},
|
},
|
||||||
where: eq(userOnboardingAnswers.userId, context.userId),
|
where: eq(userOnboardingAnswers.userId, context.userId),
|
||||||
});
|
});
|
||||||
const hostedUser = await db.query.user.findFirst({
|
const userRecord = await db.query.user.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
@ -53,7 +53,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
|||||||
return {
|
return {
|
||||||
completedAt: answers?.completedAt ?? null,
|
completedAt: answers?.completedAt ?? null,
|
||||||
gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null,
|
gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null,
|
||||||
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null,
|
userCreatedAt: userRecord?.createdAt?.toISOString() ?? null,
|
||||||
answers: {
|
answers: {
|
||||||
interestedFeatures,
|
interestedFeatures,
|
||||||
workFor: answers?.workFor ?? null,
|
workFor: answers?.workFor ?? null,
|
||||||
|
|||||||
@ -2,3 +2,13 @@
|
|||||||
* Kept in `shared` so both server (auth config, GSC client) and client (connect
|
* Kept in `shared` so both server (auth config, GSC client) and client (connect
|
||||||
* button) can reference it without importing the server-only auth config. */
|
* button) can reference it without importing the server-only auth config. */
|
||||||
export const GSC_OAUTH_PROVIDER_ID = "google-search-console";
|
export const GSC_OAUTH_PROVIDER_ID = "google-search-console";
|
||||||
|
|
||||||
|
export const GSC_OAUTH_SCOPES = [
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
"profile",
|
||||||
|
"https://www.googleapis.com/auth/webmasters.readonly",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const GSC_SELF_HOSTED_SETUP_DOCS_URL =
|
||||||
|
"https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user