@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.
@@ -0,0 +1,107 @@
1
+ import {
2
+ GoogleRefreshTokenRevokedError,
3
+ GSC_ACCESS_REVOKED_MESSAGE,
4
+ refreshAccessToken,
5
+ } from './google';
6
+ import {
7
+ deleteUser,
8
+ getDecryptedRefreshToken,
9
+ } from './storage';
10
+
11
+ export interface GoogleAccessTokenEnv {
12
+ USER_KV: KVNamespace;
13
+ TOKEN_ENCRYPTION_KEY: string;
14
+ GOOGLE_CLIENT_ID: string;
15
+ GOOGLE_CLIENT_SECRET: string;
16
+ }
17
+
18
+ export interface GoogleAccessTokenProvider {
19
+ getAccessToken(googleId: string): Promise<string>;
20
+ }
21
+
22
+ interface AccessTokenCacheEntry {
23
+ token: string;
24
+ expires_at: number;
25
+ }
26
+
27
+ export interface AccessTokenLifecycleDependencies {
28
+ getDecryptedRefreshToken: typeof getDecryptedRefreshToken;
29
+ refreshAccessToken: typeof refreshAccessToken;
30
+ deleteUser: typeof deleteUser;
31
+ now: () => number;
32
+ }
33
+
34
+ const DEFAULT_DEPENDENCIES: AccessTokenLifecycleDependencies = {
35
+ getDecryptedRefreshToken,
36
+ refreshAccessToken,
37
+ deleteUser,
38
+ now: Date.now,
39
+ };
40
+
41
+ /**
42
+ * Keeps access tokens in memory only for the lifetime of the provider. It
43
+ * centralizes refresh-token revocation handling for every Google API caller.
44
+ */
45
+ export class GoogleAccessTokenLifecycle implements GoogleAccessTokenProvider {
46
+ private readonly cache = new Map<string, AccessTokenCacheEntry>();
47
+ private readonly inFlightRefreshes = new Map<string, Promise<string>>();
48
+ private readonly dependencies: AccessTokenLifecycleDependencies;
49
+
50
+ constructor(
51
+ private readonly env: GoogleAccessTokenEnv,
52
+ dependencies: Partial<AccessTokenLifecycleDependencies> = {},
53
+ ) {
54
+ this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
55
+ }
56
+
57
+ async getAccessToken(googleId: string): Promise<string> {
58
+ const now = this.dependencies.now();
59
+ const cached = this.cache.get(googleId);
60
+ if (cached && cached.expires_at > now + 60_000) {
61
+ return cached.token;
62
+ }
63
+
64
+ const inFlight = this.inFlightRefreshes.get(googleId);
65
+ if (inFlight) return inFlight;
66
+
67
+ const refresh = this.refreshAndCache(googleId);
68
+ this.inFlightRefreshes.set(googleId, refresh);
69
+ try {
70
+ return await refresh;
71
+ } finally {
72
+ if (this.inFlightRefreshes.get(googleId) === refresh) {
73
+ this.inFlightRefreshes.delete(googleId);
74
+ }
75
+ }
76
+ }
77
+
78
+ private async refreshAndCache(googleId: string): Promise<string> {
79
+ const refreshToken = await this.dependencies.getDecryptedRefreshToken(
80
+ this.env,
81
+ googleId,
82
+ );
83
+ if (!refreshToken) {
84
+ throw new Error(GSC_ACCESS_REVOKED_MESSAGE);
85
+ }
86
+
87
+ try {
88
+ const tokens = await this.dependencies.refreshAccessToken(
89
+ refreshToken,
90
+ this.env.GOOGLE_CLIENT_ID,
91
+ this.env.GOOGLE_CLIENT_SECRET,
92
+ );
93
+ this.cache.set(googleId, {
94
+ token: tokens.access_token,
95
+ expires_at: this.dependencies.now() + tokens.expires_in * 1000,
96
+ });
97
+ return tokens.access_token;
98
+ } catch (err) {
99
+ if (err instanceof GoogleRefreshTokenRevokedError) {
100
+ this.cache.delete(googleId);
101
+ await this.dependencies.deleteUser(this.env, googleId);
102
+ console.warn('Refresh token revoked; deleted stored user credentials');
103
+ }
104
+ throw err;
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+
3
+ export const CANNIBALIZATION_MIN_IMPRESSIONS_SCHEMA = z
4
+ .number()
5
+ .int()
6
+ .min(0)
7
+ .default(50)
8
+ .describe('Minimum impressions for a page-query pair to be considered. Must be at least 0. Default is 50.');
9
+
10
+ export const CANNIBALIZATION_MIN_PAGE_PERCENTAGE_SCHEMA = z
11
+ .number()
12
+ .min(0)
13
+ .max(100)
14
+ .default(10)
15
+ .describe('Minimum percentage of total query impressions a page must have to count as a cannibalizing page (0-100). Default is 10%.');
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+
3
+ export const CONTENT_DECAY_COMPARE_DAYS_SCHEMA = z
4
+ .number()
5
+ .int()
6
+ .min(1)
7
+ .default(30)
8
+ .describe('Number of days to compare (recent period vs previous period). Must be at least 1. Default is 30.');
package/src/crypto.ts CHANGED
@@ -1,58 +1,80 @@
1
- function base64Decode(b64: string): Uint8Array<ArrayBuffer> {
2
- const binary = atob(b64);
3
- const buf = new ArrayBuffer(binary.length);
4
- const bytes = new Uint8Array(buf);
5
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
6
- return bytes;
7
- }
8
-
9
- function base64Encode(bytes: Uint8Array): string {
10
- let binary = '';
11
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
12
- return btoa(binary);
13
- }
14
-
15
- async function importKey(base64Key: string): Promise<CryptoKey> {
16
- const keyBytes = base64Decode(base64Key);
17
- return crypto.subtle.importKey(
18
- 'raw',
19
- keyBytes,
20
- { name: 'AES-GCM' },
21
- false,
22
- ['encrypt', 'decrypt'],
23
- );
24
- }
25
-
26
- export async function encryptToken(
27
- plaintext: string,
28
- base64Key: string,
29
- ): Promise<{ ciphertext: string; iv: string }> {
30
- const key = await importKey(base64Key);
31
- const iv = crypto.getRandomValues(new Uint8Array(12));
32
- const encoded = new TextEncoder().encode(plaintext);
33
- const ciphertext = await crypto.subtle.encrypt(
34
- { name: 'AES-GCM', iv },
35
- key,
36
- encoded,
37
- );
38
- return {
39
- ciphertext: base64Encode(new Uint8Array(ciphertext)),
40
- iv: base64Encode(iv),
41
- };
42
- }
43
-
44
- export async function decryptToken(
45
- ciphertext: string,
46
- iv: string,
47
- base64Key: string,
48
- ): Promise<string> {
49
- const key = await importKey(base64Key);
50
- const ctBytes = base64Decode(ciphertext);
51
- const ivBytes = base64Decode(iv);
52
- const decrypted = await crypto.subtle.decrypt(
53
- { name: 'AES-GCM', iv: ivBytes },
54
- key,
55
- ctBytes,
56
- );
57
- return new TextDecoder().decode(decrypted);
58
- }
1
+ function base64Decode(b64: string): Uint8Array<ArrayBuffer> {
2
+ const binary = atob(b64);
3
+ const buf = new ArrayBuffer(binary.length);
4
+ const bytes = new Uint8Array(buf);
5
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
6
+ return bytes;
7
+ }
8
+
9
+ function base64Encode(bytes: Uint8Array): string {
10
+ let binary = '';
11
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
12
+ return btoa(binary);
13
+ }
14
+
15
+ const TOKEN_ENCRYPTION_KEY_ERROR =
16
+ 'Invalid TOKEN_ENCRYPTION_KEY configuration: expected a valid base64-encoded 32-byte (256-bit) AES key.';
17
+
18
+ function decodeTokenEncryptionKey(base64Key: string): Uint8Array<ArrayBuffer> {
19
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64Key)) {
20
+ throw new Error(TOKEN_ENCRYPTION_KEY_ERROR);
21
+ }
22
+
23
+ let keyBytes: Uint8Array<ArrayBuffer>;
24
+ try {
25
+ keyBytes = base64Decode(base64Key);
26
+ } catch {
27
+ throw new Error(TOKEN_ENCRYPTION_KEY_ERROR);
28
+ }
29
+
30
+ if (keyBytes.byteLength !== 32) {
31
+ throw new Error(TOKEN_ENCRYPTION_KEY_ERROR);
32
+ }
33
+
34
+ return keyBytes;
35
+ }
36
+
37
+ async function importKey(base64Key: string): Promise<CryptoKey> {
38
+ const keyBytes = decodeTokenEncryptionKey(base64Key);
39
+ return crypto.subtle.importKey(
40
+ 'raw',
41
+ keyBytes,
42
+ { name: 'AES-GCM' },
43
+ false,
44
+ ['encrypt', 'decrypt'],
45
+ );
46
+ }
47
+
48
+ export async function encryptToken(
49
+ plaintext: string,
50
+ base64Key: string,
51
+ ): Promise<{ ciphertext: string; iv: string }> {
52
+ const key = await importKey(base64Key);
53
+ const iv = crypto.getRandomValues(new Uint8Array(12));
54
+ const encoded = new TextEncoder().encode(plaintext);
55
+ const ciphertext = await crypto.subtle.encrypt(
56
+ { name: 'AES-GCM', iv },
57
+ key,
58
+ encoded,
59
+ );
60
+ return {
61
+ ciphertext: base64Encode(new Uint8Array(ciphertext)),
62
+ iv: base64Encode(iv),
63
+ };
64
+ }
65
+
66
+ export async function decryptToken(
67
+ ciphertext: string,
68
+ iv: string,
69
+ base64Key: string,
70
+ ): Promise<string> {
71
+ const key = await importKey(base64Key);
72
+ const ctBytes = base64Decode(ciphertext);
73
+ const ivBytes = base64Decode(iv);
74
+ const decrypted = await crypto.subtle.decrypt(
75
+ { name: 'AES-GCM', iv: ivBytes },
76
+ key,
77
+ ctBytes,
78
+ );
79
+ return new TextDecoder().decode(decrypted);
80
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from 'zod';
2
+
3
+ const YYYY_MM_DD = /^(\d{4})-(\d{2})-(\d{2})$/;
4
+ const SEARCH_CONSOLE_TIME_ZONE = 'America/Los_Angeles';
5
+
6
+ export function getSearchConsoleCalendarDate(now: Date = new Date()): string {
7
+ const parts = new Intl.DateTimeFormat('en-US', {
8
+ timeZone: SEARCH_CONSOLE_TIME_ZONE,
9
+ year: 'numeric',
10
+ month: '2-digit',
11
+ day: '2-digit',
12
+ }).formatToParts(now);
13
+ const value = (type: Intl.DateTimeFormatPartTypes) =>
14
+ parts.find((part) => part.type === type)?.value;
15
+ const year = value('year');
16
+ const month = value('month');
17
+ const day = value('day');
18
+ if (!year || !month || !day) {
19
+ throw new Error('Could not resolve the Search Console calendar date.');
20
+ }
21
+ return `${year}-${month}-${day}`;
22
+ }
23
+
24
+ export function isValidCalendarDate(value: string): boolean {
25
+ const match = YYYY_MM_DD.exec(value);
26
+ if (!match) return false;
27
+
28
+ const year = Number(match[1]);
29
+ const month = Number(match[2]);
30
+ const day = Number(match[3]);
31
+ if (month < 1 || month > 12 || day < 1) return false;
32
+
33
+ const daysInMonth = [
34
+ 31,
35
+ (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28,
36
+ 31,
37
+ 30,
38
+ 31,
39
+ 30,
40
+ 31,
41
+ 31,
42
+ 30,
43
+ 31,
44
+ 30,
45
+ 31,
46
+ ];
47
+ return day <= daysInMonth[month - 1];
48
+ }
49
+
50
+ export const SEARCH_CONSOLE_DATE_SCHEMA = z.string().superRefine((value, ctx) => {
51
+ if (!YYYY_MM_DD.test(value)) {
52
+ ctx.addIssue({
53
+ code: 'custom',
54
+ message: 'Use YYYY-MM-DD format (for example, 2026-01-31).',
55
+ });
56
+ return;
57
+ }
58
+
59
+ if (!isValidCalendarDate(value)) {
60
+ ctx.addIssue({
61
+ code: 'custom',
62
+ message: 'Use a real calendar date in YYYY-MM-DD format (for example, 2024-02-29).',
63
+ });
64
+ }
65
+ });
66
+
67
+ export function assertDateRange(
68
+ startDate: string,
69
+ endDate: string,
70
+ startName = 'start_date',
71
+ endName = 'end_date',
72
+ ): void {
73
+ if (startDate > endDate) {
74
+ throw new Error(`${startName} must be on or before ${endName}.`);
75
+ }
76
+ }
77
+
78
+ export function assertDateNotInFuture(date: string, today: string): void {
79
+ if (date > today) {
80
+ throw new Error(
81
+ 'End date must be today or earlier. Google Search Console has no data for dates that have not happened yet.',
82
+ );
83
+ }
84
+ }