@digestseo/mcp-gsc 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars.example +12 -8
- package/CHANGELOG.md +80 -40
- package/LICENSE +21 -21
- package/README.md +111 -96
- package/SECURITY.md +73 -36
- package/SETUP.md +217 -199
- package/package.json +27 -13
- package/scripts/glama-start.mjs +14 -3
- package/src/access-mode.ts +59 -0
- package/src/access-token-lifecycle.ts +107 -0
- package/src/cannibalization-schema.ts +15 -0
- package/src/content-decay-schema.ts +8 -0
- package/src/crypto.ts +80 -58
- package/src/date-validation.ts +84 -0
- package/src/digest.ts +632 -648
- package/src/entrypoint.ts +39 -0
- package/src/google.ts +1205 -716
- package/src/index.ts +1431 -908
- package/src/indexed-pages-range.ts +54 -0
- package/src/indexing-property-authorization.ts +119 -0
- package/src/pending-auth-state.ts +87 -0
- package/src/quick-wins-schema.ts +27 -0
- package/src/storage.ts +65 -98
- package/src/tool-rate-limit.ts +231 -0
- package/src/tool-rate-limiter-do.ts +23 -0
- package/src/write-tool-annotations.ts +43 -0
- package/wrangler.example.jsonc +54 -35
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { assertDateRange } from './date-validation';
|
|
2
|
+
|
|
3
|
+
export interface IndexedPagesDateRange {
|
|
4
|
+
startDate: string;
|
|
5
|
+
endDate: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function addUtcDays(date: string, days: number): string {
|
|
9
|
+
const result = new Date(`${date}T00:00:00Z`);
|
|
10
|
+
result.setUTCDate(result.getUTCDate() + days);
|
|
11
|
+
return result.toISOString().slice(0, 10);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveIndexedPagesDateRange(
|
|
15
|
+
startDate: string | undefined,
|
|
16
|
+
endDate: string | undefined,
|
|
17
|
+
today: string,
|
|
18
|
+
): IndexedPagesDateRange {
|
|
19
|
+
const latestCompleteDate = addUtcDays(today, -3);
|
|
20
|
+
|
|
21
|
+
if (!startDate && !endDate) {
|
|
22
|
+
return {
|
|
23
|
+
startDate: addUtcDays(latestCompleteDate, -29),
|
|
24
|
+
endDate: latestCompleteDate,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!startDate) {
|
|
29
|
+
return {
|
|
30
|
+
startDate: addUtcDays(endDate!, -29),
|
|
31
|
+
endDate: endDate!,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!endDate) {
|
|
36
|
+
if (startDate > latestCompleteDate) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`start_date must be on or before ${latestCompleteDate} when end_date is omitted. ` +
|
|
39
|
+
'The generated end_date cannot be later than the latest complete date.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const generatedEndDate = addUtcDays(startDate, 29);
|
|
43
|
+
return {
|
|
44
|
+
startDate,
|
|
45
|
+
endDate:
|
|
46
|
+
generatedEndDate > latestCompleteDate
|
|
47
|
+
? latestCompleteDate
|
|
48
|
+
: generatedEndDate,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assertDateRange(startDate, endDate);
|
|
53
|
+
return { startDate, endDate };
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface SearchConsoleSite {
|
|
2
|
+
siteUrl: string;
|
|
3
|
+
permissionLevel: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SITE_OWNER_PERMISSION = 'siteOwner';
|
|
7
|
+
const DOMAIN_PROPERTY_PREFIX = 'sc-domain:';
|
|
8
|
+
|
|
9
|
+
function parseHttpUrl(value: string, fieldName: string): URL {
|
|
10
|
+
if (value !== value.trim() || !/^https?:\/\//i.test(value)) {
|
|
11
|
+
throw new Error(`${fieldName} must be a fully qualified HTTP or HTTPS URL.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let url: URL;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`${fieldName} must be a fully qualified HTTP or HTTPS URL.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
22
|
+
throw new Error(`${fieldName} must use the HTTP or HTTPS scheme.`);
|
|
23
|
+
}
|
|
24
|
+
if (!url.hostname) {
|
|
25
|
+
throw new Error(`${fieldName} must include a hostname.`);
|
|
26
|
+
}
|
|
27
|
+
if (url.username || url.password) {
|
|
28
|
+
throw new Error(`${fieldName} must not include userinfo.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseDomainProperty(siteUrl: string): string {
|
|
35
|
+
const domain = siteUrl.slice(DOMAIN_PROPERTY_PREFIX.length);
|
|
36
|
+
if (!domain || /[/?#@:]/.test(domain)) {
|
|
37
|
+
throw new Error('site_url is not a valid Search Console domain property.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parsed = parseHttpUrl(`https://${domain}`, 'site_url');
|
|
41
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.port) {
|
|
42
|
+
throw new Error('site_url is not a valid Search Console domain property.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return parsed.hostname;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeSiteUrl(siteUrl: string): string {
|
|
49
|
+
if (siteUrl.startsWith(DOMAIN_PROPERTY_PREFIX)) {
|
|
50
|
+
return `${DOMAIN_PROPERTY_PREFIX}${parseDomainProperty(siteUrl)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parsed = parseHttpUrl(siteUrl, 'site_url');
|
|
54
|
+
if (parsed.search || parsed.hash) {
|
|
55
|
+
throw new Error('site_url must not include a query string or fragment.');
|
|
56
|
+
}
|
|
57
|
+
return parsed.href;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isWithinUrlPrefix(target: URL, property: URL): boolean {
|
|
61
|
+
if (target.origin !== property.origin) return false;
|
|
62
|
+
|
|
63
|
+
const propertyPath = property.pathname;
|
|
64
|
+
if (propertyPath === '/') return true;
|
|
65
|
+
const descendantPrefix = propertyPath.endsWith('/')
|
|
66
|
+
? propertyPath
|
|
67
|
+
: `${propertyPath}/`;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
target.pathname === propertyPath ||
|
|
71
|
+
target.pathname.startsWith(descendantPrefix)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isWithinDomainProperty(target: URL, domain: string): boolean {
|
|
76
|
+
return target.hostname === domain || target.hostname.endsWith(`.${domain}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Confirms that an Indexing API URL is contained by an owner-level Search
|
|
81
|
+
* Console property available to the authenticated account. URL parsing avoids
|
|
82
|
+
* string-prefix and userinfo lookalike bypasses before any page fetch occurs.
|
|
83
|
+
*/
|
|
84
|
+
export function assertIndexingUrlAuthorized(
|
|
85
|
+
url: string,
|
|
86
|
+
siteUrl: string,
|
|
87
|
+
sites: SearchConsoleSite[],
|
|
88
|
+
): void {
|
|
89
|
+
const target = parseHttpUrl(url, 'url');
|
|
90
|
+
const normalizedSiteUrl = normalizeSiteUrl(siteUrl);
|
|
91
|
+
const authorizedSite = sites.find((site) => {
|
|
92
|
+
if (site.permissionLevel !== SITE_OWNER_PERMISSION) return false;
|
|
93
|
+
try {
|
|
94
|
+
return normalizeSiteUrl(site.siteUrl) === normalizedSiteUrl;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!authorizedSite) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'site_url must be a Search Console property where the authenticated Google account is an owner.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (normalizedSiteUrl.startsWith(DOMAIN_PROPERTY_PREFIX)) {
|
|
107
|
+
if (isWithinDomainProperty(target, parseDomainProperty(normalizedSiteUrl))) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} else if (isWithinUrlPrefix(target, new URL(normalizedSiteUrl))) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error('url must belong to the authorized site_url Search Console property.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function assertIndexingRequestUrl(url: string): void {
|
|
118
|
+
parseHttpUrl(url, 'url');
|
|
119
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const PENDING_AUTH_TTL_MS = 10 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
export interface PendingAuthRequest {
|
|
4
|
+
claudeAuthRequest: unknown;
|
|
5
|
+
created_at: number;
|
|
6
|
+
expires_at: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PendingAuthStateEnv {
|
|
10
|
+
PENDING_AUTH_STATE: DurableObjectNamespace;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PendingAuthStateStub {
|
|
14
|
+
store(claudeAuthRequest: unknown): Promise<void>;
|
|
15
|
+
consume(): Promise<PendingAuthRequest | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One instance is addressed by one OAuth nonce. Its storage transaction makes
|
|
20
|
+
* consumption a single read-check-delete operation, even for concurrent
|
|
21
|
+
* callbacks routed to the same Durable Object.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Storage-only pending authorization state. Keeping this independent from the
|
|
25
|
+
* Worker runtime lets the single-use/expiry semantics stay covered by the
|
|
26
|
+
* fast Node test suite while the exported Durable Object delegates to it.
|
|
27
|
+
*/
|
|
28
|
+
export class PendingAuthStateStore {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly storage: DurableObjectStorage,
|
|
31
|
+
private readonly now: () => number = Date.now,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
async store(claudeAuthRequest: unknown): Promise<void> {
|
|
35
|
+
const created_at = this.now();
|
|
36
|
+
const record: PendingAuthRequest = {
|
|
37
|
+
claudeAuthRequest,
|
|
38
|
+
created_at,
|
|
39
|
+
expires_at: created_at + PENDING_AUTH_TTL_MS,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
await Promise.all([
|
|
43
|
+
this.storage.put('pending', record),
|
|
44
|
+
this.storage.setAlarm(record.expires_at),
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async consume(): Promise<PendingAuthRequest | null> {
|
|
49
|
+
return this.storage.transaction(async (txn) => {
|
|
50
|
+
const record = await txn.get<PendingAuthRequest>('pending');
|
|
51
|
+
if (!record) return null;
|
|
52
|
+
|
|
53
|
+
await txn.delete('pending');
|
|
54
|
+
await txn.deleteAlarm();
|
|
55
|
+
|
|
56
|
+
return record.expires_at > this.now() ? record : null;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async alarm(): Promise<void> {
|
|
61
|
+
await this.storage.deleteAll();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getPendingAuthStateStub(
|
|
66
|
+
env: PendingAuthStateEnv,
|
|
67
|
+
nonce: string,
|
|
68
|
+
): PendingAuthStateStub {
|
|
69
|
+
return env.PENDING_AUTH_STATE.get(
|
|
70
|
+
env.PENDING_AUTH_STATE.idFromName(nonce),
|
|
71
|
+
) as unknown as PendingAuthStateStub;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function stashPendingAuth(
|
|
75
|
+
env: PendingAuthStateEnv,
|
|
76
|
+
nonce: string,
|
|
77
|
+
claudeAuthRequest: unknown,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
await getPendingAuthStateStub(env, nonce).store(claudeAuthRequest);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function consumePendingAuth(
|
|
83
|
+
env: PendingAuthStateEnv,
|
|
84
|
+
nonce: string,
|
|
85
|
+
): Promise<PendingAuthRequest | null> {
|
|
86
|
+
return getPendingAuthStateStub(env, nonce).consume();
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SEARCH_CONSOLE_DATE_SCHEMA } from './date-validation';
|
|
3
|
+
|
|
4
|
+
const POSITION_RANGE_ERROR = {
|
|
5
|
+
message: 'min_position must be less than or equal to max_position.',
|
|
6
|
+
path: ['max_position'],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
10
|
+
min_impressions: z.number().int().min(0).default(100).describe('Minimum impressions required to consider a query. Must be at least 0. Default is 100.'),
|
|
11
|
+
min_position: z.number().positive().default(8).describe('Minimum average position to target (inclusive). Must be greater than 0. Default is 8.'),
|
|
12
|
+
max_position: z.number().positive().default(20).describe('Maximum average position to target (inclusive). Must be greater than 0. Default is 20.'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
16
|
+
return z
|
|
17
|
+
.object({
|
|
18
|
+
site_url: z.string().describe(siteUrlDescription),
|
|
19
|
+
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
20
|
+
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
21
|
+
...QUICK_WINS_THRESHOLDS_SHAPE,
|
|
22
|
+
})
|
|
23
|
+
.refine(
|
|
24
|
+
({ min_position, max_position }) => min_position <= max_position,
|
|
25
|
+
POSITION_RANGE_ERROR,
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -1,98 +1,65 @@
|
|
|
1
|
-
import { encryptToken, decryptToken } from './crypto';
|
|
2
|
-
|
|
3
|
-
interface StorageEnv {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function
|
|
17
|
-
env: StorageEnv,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
googleId
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
updated_at: now,
|
|
67
|
-
};
|
|
68
|
-
await env.USER_KV.put(`user:${googleId}`, JSON.stringify(record));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export async function getUser(
|
|
72
|
-
env: StorageEnv,
|
|
73
|
-
googleId: string,
|
|
74
|
-
): Promise<UserRecord | null> {
|
|
75
|
-
const raw = await env.USER_KV.get(`user:${googleId}`);
|
|
76
|
-
if (!raw) return null;
|
|
77
|
-
return JSON.parse(raw) as UserRecord;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function deleteUser(
|
|
81
|
-
env: StorageEnv,
|
|
82
|
-
googleId: string,
|
|
83
|
-
): Promise<void> {
|
|
84
|
-
await env.USER_KV.delete(`user:${googleId}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export async function getDecryptedRefreshToken(
|
|
88
|
-
env: StorageEnv,
|
|
89
|
-
googleId: string,
|
|
90
|
-
): Promise<string | null> {
|
|
91
|
-
const user = await getUser(env, googleId);
|
|
92
|
-
if (!user) return null;
|
|
93
|
-
return await decryptToken(
|
|
94
|
-
user.encrypted_refresh_token,
|
|
95
|
-
user.iv,
|
|
96
|
-
env.TOKEN_ENCRYPTION_KEY,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
1
|
+
import { encryptToken, decryptToken } from './crypto';
|
|
2
|
+
|
|
3
|
+
interface StorageEnv {
|
|
4
|
+
USER_KV: KVNamespace;
|
|
5
|
+
TOKEN_ENCRYPTION_KEY: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UserRecord {
|
|
9
|
+
email: string;
|
|
10
|
+
encrypted_refresh_token: string;
|
|
11
|
+
iv: string;
|
|
12
|
+
created_at: number;
|
|
13
|
+
updated_at: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function saveUser(
|
|
17
|
+
env: StorageEnv,
|
|
18
|
+
googleId: string,
|
|
19
|
+
email: string,
|
|
20
|
+
refreshToken: string,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const { ciphertext, iv } = await encryptToken(
|
|
23
|
+
refreshToken,
|
|
24
|
+
env.TOKEN_ENCRYPTION_KEY,
|
|
25
|
+
);
|
|
26
|
+
const existing = await getUser(env, googleId);
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const record: UserRecord = {
|
|
29
|
+
email,
|
|
30
|
+
encrypted_refresh_token: ciphertext,
|
|
31
|
+
iv,
|
|
32
|
+
created_at: existing?.created_at ?? now,
|
|
33
|
+
updated_at: now,
|
|
34
|
+
};
|
|
35
|
+
await env.USER_KV.put(`user:${googleId}`, JSON.stringify(record));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getUser(
|
|
39
|
+
env: StorageEnv,
|
|
40
|
+
googleId: string,
|
|
41
|
+
): Promise<UserRecord | null> {
|
|
42
|
+
const raw = await env.USER_KV.get(`user:${googleId}`);
|
|
43
|
+
if (!raw) return null;
|
|
44
|
+
return JSON.parse(raw) as UserRecord;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function deleteUser(
|
|
48
|
+
env: StorageEnv,
|
|
49
|
+
googleId: string,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
await env.USER_KV.delete(`user:${googleId}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getDecryptedRefreshToken(
|
|
55
|
+
env: StorageEnv,
|
|
56
|
+
googleId: string,
|
|
57
|
+
): Promise<string | null> {
|
|
58
|
+
const user = await getUser(env, googleId);
|
|
59
|
+
if (!user) return null;
|
|
60
|
+
return await decryptToken(
|
|
61
|
+
user.encrypted_refresh_token,
|
|
62
|
+
user.iv,
|
|
63
|
+
env.TOKEN_ENCRYPTION_KEY,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const MINUTE_MS = 60 * 1000;
|
|
2
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
3
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
4
|
+
|
|
5
|
+
export interface ToolRateLimitPolicy {
|
|
6
|
+
/** The coordination bucket shared by the tools in this policy. */
|
|
7
|
+
category: string;
|
|
8
|
+
tools: readonly string[];
|
|
9
|
+
windowMs: number;
|
|
10
|
+
userLimit: number;
|
|
11
|
+
/**
|
|
12
|
+
* A project-wide cap is used only where Google publishes a small, shared
|
|
13
|
+
* project quota. It is intentionally absent for ordinary GSC operations.
|
|
14
|
+
*/
|
|
15
|
+
projectLimit?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Limits apply before an upstream request is made. The values are intentionally
|
|
20
|
+
* conservative relative to Google's published quotas, because a self-hosted
|
|
21
|
+
* deployment can have multiple authenticated users sharing one Cloud project.
|
|
22
|
+
*
|
|
23
|
+
* Sources:
|
|
24
|
+
* - https://developers.google.com/webmaster-tools/limits
|
|
25
|
+
* - https://developers.google.com/search/apis/indexing-api/v3/quota-pricing
|
|
26
|
+
*/
|
|
27
|
+
export const TOOL_RATE_LIMIT_POLICIES = {
|
|
28
|
+
search_analytics: {
|
|
29
|
+
category: 'search-analytics',
|
|
30
|
+
tools: [
|
|
31
|
+
'analytics.query',
|
|
32
|
+
'insights.page_queries',
|
|
33
|
+
'insights.query_pages',
|
|
34
|
+
'insights.quick_wins',
|
|
35
|
+
'insights.cannibalization',
|
|
36
|
+
'insights.content_decay',
|
|
37
|
+
'indexing.list_pages',
|
|
38
|
+
'analytics.compare',
|
|
39
|
+
],
|
|
40
|
+
// GSC measures Search Analytics short-term load in 10-minute windows.
|
|
41
|
+
windowMs: 10 * MINUTE_MS,
|
|
42
|
+
userLimit: 30,
|
|
43
|
+
},
|
|
44
|
+
url_inspection: {
|
|
45
|
+
category: 'url-inspection',
|
|
46
|
+
tools: ['urls.inspect', 'urls.inspect_many'],
|
|
47
|
+
// This is one percent of GSC's published 2,000
|
|
48
|
+
// inspections-per-site-per-day quota for each user.
|
|
49
|
+
windowMs: DAY_MS,
|
|
50
|
+
userLimit: 20,
|
|
51
|
+
},
|
|
52
|
+
search_console_writes: {
|
|
53
|
+
category: 'search-console-write',
|
|
54
|
+
tools: ['sites.add', 'sites.delete', 'sitemaps.submit', 'sitemaps.delete'],
|
|
55
|
+
// Mutation safety guard, far below GSC's 200 requests-per-minute
|
|
56
|
+
// per-user limit for non-Search-Analytics resources.
|
|
57
|
+
windowMs: HOUR_MS,
|
|
58
|
+
userLimit: 10,
|
|
59
|
+
},
|
|
60
|
+
indexing_request: {
|
|
61
|
+
category: 'indexing-request',
|
|
62
|
+
tools: ['indexing.request'],
|
|
63
|
+
// Google defaults to 200 publish requests per project per day. Two per
|
|
64
|
+
// user reserves the default quota for up to 100 authenticated users.
|
|
65
|
+
windowMs: DAY_MS,
|
|
66
|
+
userLimit: 2,
|
|
67
|
+
projectLimit: 200,
|
|
68
|
+
},
|
|
69
|
+
weekly_digest: {
|
|
70
|
+
category: 'weekly-digest',
|
|
71
|
+
tools: ['reports.weekly_digest'],
|
|
72
|
+
// A digest makes five Search Analytics requests, so it has its own bucket.
|
|
73
|
+
windowMs: HOUR_MS,
|
|
74
|
+
userLimit: 6,
|
|
75
|
+
},
|
|
76
|
+
} as const satisfies Record<string, ToolRateLimitPolicy>;
|
|
77
|
+
|
|
78
|
+
export type ToolRateLimitCategory = keyof typeof TOOL_RATE_LIMIT_POLICIES;
|
|
79
|
+
export type RateLimitedToolName =
|
|
80
|
+
(typeof TOOL_RATE_LIMIT_POLICIES)[ToolRateLimitCategory]['tools'][number];
|
|
81
|
+
|
|
82
|
+
const POLICY_BY_TOOL = new Map<string, ToolRateLimitPolicy>(
|
|
83
|
+
Object.values(TOOL_RATE_LIMIT_POLICIES).flatMap((policy) =>
|
|
84
|
+
policy.tools.map((tool) => [tool, policy] as const),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
interface RateLimitWindow {
|
|
89
|
+
window_started_at: number;
|
|
90
|
+
count: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ToolRateLimitResult {
|
|
94
|
+
allowed: boolean;
|
|
95
|
+
retry_after_ms?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ToolRateLimiterEnv {
|
|
99
|
+
TOOL_RATE_LIMITER: DurableObjectNamespace;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ToolRateLimiterStub {
|
|
103
|
+
take(
|
|
104
|
+
userBucket: string,
|
|
105
|
+
policy: ToolRateLimitPolicy,
|
|
106
|
+
units?: number,
|
|
107
|
+
): Promise<ToolRateLimitResult>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function currentWindow(
|
|
111
|
+
value: RateLimitWindow | undefined,
|
|
112
|
+
now: number,
|
|
113
|
+
windowMs: number,
|
|
114
|
+
): RateLimitWindow {
|
|
115
|
+
if (!value || now >= value.window_started_at + windowMs) {
|
|
116
|
+
return { window_started_at: now, count: 0 };
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function retryAfterMs(window: RateLimitWindow, now: number, windowMs: number): number {
|
|
122
|
+
return Math.max(0, window.window_started_at + windowMs - now);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Coordinates one policy category. Ordinary categories are sharded by the
|
|
127
|
+
* authenticated user's hashed bucket. Indexing uses one category instance so
|
|
128
|
+
* its small Google project quota can be enforced together with user limits.
|
|
129
|
+
*/
|
|
130
|
+
export class ToolRateLimiterCore {
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly state: DurableObjectState,
|
|
133
|
+
_env: unknown,
|
|
134
|
+
private readonly now: () => number = Date.now,
|
|
135
|
+
) {}
|
|
136
|
+
|
|
137
|
+
async take(
|
|
138
|
+
userBucket: string,
|
|
139
|
+
policy: ToolRateLimitPolicy,
|
|
140
|
+
units = 1,
|
|
141
|
+
): Promise<ToolRateLimitResult> {
|
|
142
|
+
if (!Number.isInteger(units) || units < 1) {
|
|
143
|
+
throw new Error('Rate-limit units must be a positive integer.');
|
|
144
|
+
}
|
|
145
|
+
return this.state.storage.transaction(async (txn) => {
|
|
146
|
+
const now = this.now();
|
|
147
|
+
const userKey = `user:${userBucket}`;
|
|
148
|
+
const userWindow = currentWindow(
|
|
149
|
+
await txn.get<RateLimitWindow>(userKey),
|
|
150
|
+
now,
|
|
151
|
+
policy.windowMs,
|
|
152
|
+
);
|
|
153
|
+
const projectWindow = policy.projectLimit === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: currentWindow(
|
|
156
|
+
await txn.get<RateLimitWindow>('project'),
|
|
157
|
+
now,
|
|
158
|
+
policy.windowMs,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const userExceeded = userWindow.count + units > policy.userLimit;
|
|
162
|
+
const projectExceeded = projectWindow !== undefined
|
|
163
|
+
&& projectWindow.count + units > policy.projectLimit!;
|
|
164
|
+
if (userExceeded || projectExceeded) {
|
|
165
|
+
const waits = [
|
|
166
|
+
...(userExceeded
|
|
167
|
+
? [retryAfterMs(userWindow, now, policy.windowMs)]
|
|
168
|
+
: []),
|
|
169
|
+
...(projectExceeded && projectWindow
|
|
170
|
+
? [retryAfterMs(projectWindow, now, policy.windowMs)]
|
|
171
|
+
: []),
|
|
172
|
+
];
|
|
173
|
+
return {
|
|
174
|
+
allowed: false,
|
|
175
|
+
// If both limits apply, waiting for the longer window guarantees the
|
|
176
|
+
// retry will not immediately encounter the other limit.
|
|
177
|
+
retry_after_ms: Math.max(...waits),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await txn.put(userKey, { ...userWindow, count: userWindow.count + units });
|
|
182
|
+
if (projectWindow) {
|
|
183
|
+
await txn.put('project', {
|
|
184
|
+
...projectWindow,
|
|
185
|
+
count: projectWindow.count + units,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return { allowed: true };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getToolRateLimitPolicy(toolName: string): ToolRateLimitPolicy | undefined {
|
|
194
|
+
return POLICY_BY_TOOL.get(toolName);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function userBucketFor(googleId: string): Promise<string> {
|
|
198
|
+
const digest = await crypto.subtle.digest(
|
|
199
|
+
'SHA-256',
|
|
200
|
+
new TextEncoder().encode(googleId),
|
|
201
|
+
);
|
|
202
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getToolRateLimiterStub(
|
|
206
|
+
env: ToolRateLimiterEnv,
|
|
207
|
+
policy: ToolRateLimitPolicy,
|
|
208
|
+
userBucket: string,
|
|
209
|
+
): ToolRateLimiterStub {
|
|
210
|
+
// The Indexing API's low project-wide quota is the coordination atom for
|
|
211
|
+
// that category. All other policies are sharded by user and category.
|
|
212
|
+
const name = policy.projectLimit === undefined
|
|
213
|
+
? `${policy.category}:${userBucket}`
|
|
214
|
+
: policy.category;
|
|
215
|
+
return env.TOOL_RATE_LIMITER.get(
|
|
216
|
+
env.TOOL_RATE_LIMITER.idFromName(name),
|
|
217
|
+
) as unknown as ToolRateLimiterStub;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function enforceToolRateLimit(
|
|
221
|
+
env: ToolRateLimiterEnv,
|
|
222
|
+
googleId: string,
|
|
223
|
+
toolName: string,
|
|
224
|
+
units = 1,
|
|
225
|
+
): Promise<ToolRateLimitResult> {
|
|
226
|
+
const policy = getToolRateLimitPolicy(toolName);
|
|
227
|
+
if (!policy) return { allowed: true };
|
|
228
|
+
|
|
229
|
+
const userBucket = await userBucketFor(googleId);
|
|
230
|
+
return getToolRateLimiterStub(env, policy, userBucket).take(userBucket, policy, units);
|
|
231
|
+
}
|