@digestseo/mcp-gsc 0.3.1 → 0.4.1
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 +94 -40
- package/LICENSE +21 -21
- package/README.md +115 -98
- 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 +668 -648
- package/src/entrypoint.ts +39 -0
- package/src/google.ts +1334 -716
- package/src/index.ts +1667 -913
- 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 +44 -0
- package/src/result-bounds.ts +91 -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,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SEARCH_CONSOLE_DATE_SCHEMA } from './date-validation';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ANALYSIS_RESULT_LIMIT,
|
|
5
|
+
MAX_ANALYSIS_RESULT_LIMIT,
|
|
6
|
+
} from './result-bounds';
|
|
7
|
+
|
|
8
|
+
const POSITION_RANGE_ERROR = {
|
|
9
|
+
message: 'min_position must be less than or equal to max_position.',
|
|
10
|
+
path: ['max_position'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const QUICK_WINS_THRESHOLDS_SHAPE = {
|
|
14
|
+
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.'),
|
|
15
|
+
min_position: z.number().positive().default(8).describe('Minimum average position to target (inclusive). Must be greater than 0. Default is 8.'),
|
|
16
|
+
max_position: z.number().positive().default(20).describe('Maximum average position to target (inclusive). Must be greater than 0. Default is 20.'),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createQuickWinsInputSchema(siteUrlDescription: string) {
|
|
20
|
+
return z
|
|
21
|
+
.object({
|
|
22
|
+
site_url: z.string().describe(siteUrlDescription),
|
|
23
|
+
start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
|
|
24
|
+
end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day GSC data lag.'),
|
|
25
|
+
...QUICK_WINS_THRESHOLDS_SHAPE,
|
|
26
|
+
limit: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(MAX_ANALYSIS_RESULT_LIMIT)
|
|
31
|
+
.default(DEFAULT_ANALYSIS_RESULT_LIMIT)
|
|
32
|
+
.describe('Maximum ranked quick-win results to return in this response.'),
|
|
33
|
+
start_row: z
|
|
34
|
+
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(0)
|
|
37
|
+
.default(0)
|
|
38
|
+
.describe('Zero-based offset into the ranked quick-win result list.'),
|
|
39
|
+
})
|
|
40
|
+
.refine(
|
|
41
|
+
({ min_position, max_position }) => min_position <= max_position,
|
|
42
|
+
POSITION_RANGE_ERROR,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const STRUCTURED_RESULT_BYTE_BUDGET = 48_000;
|
|
2
|
+
export const DEFAULT_ANALYSIS_RESULT_LIMIT = 50;
|
|
3
|
+
export const MAX_ANALYSIS_RESULT_LIMIT = 100;
|
|
4
|
+
export const MAX_DIRECT_SOURCE_ROWS = 500;
|
|
5
|
+
|
|
6
|
+
export interface BoundedItems<T> {
|
|
7
|
+
items: T[];
|
|
8
|
+
byteLimitReached: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function jsonByteLength(value: unknown): number {
|
|
12
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keep one structured-content list inside a conservative byte budget.
|
|
17
|
+
*
|
|
18
|
+
* Count limits alone are not sufficient because Search Console keys are
|
|
19
|
+
* caller-controlled strings (queries and URLs). This helper therefore applies
|
|
20
|
+
* both the requested item ceiling and an encoded-JSON byte ceiling.
|
|
21
|
+
*/
|
|
22
|
+
export function takeBoundedItems<T>(
|
|
23
|
+
items: readonly T[],
|
|
24
|
+
maxItems: number,
|
|
25
|
+
byteBudget = STRUCTURED_RESULT_BYTE_BUDGET,
|
|
26
|
+
): BoundedItems<T> {
|
|
27
|
+
if (!Number.isInteger(maxItems) || maxItems < 1) {
|
|
28
|
+
throw new Error('maxItems must be a positive integer.');
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(byteBudget) || byteBudget < 1) {
|
|
31
|
+
throw new Error('byteBudget must be a positive integer.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bounded: T[] = [];
|
|
35
|
+
// Account for the surrounding JSON array. Per-item commas are added below.
|
|
36
|
+
let usedBytes = 2;
|
|
37
|
+
let byteLimitReached = false;
|
|
38
|
+
|
|
39
|
+
for (const item of items.slice(0, maxItems)) {
|
|
40
|
+
const itemBytes = jsonByteLength(item) + (bounded.length > 0 ? 1 : 0);
|
|
41
|
+
if (usedBytes + itemBytes > byteBudget) {
|
|
42
|
+
byteLimitReached = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
bounded.push(item);
|
|
46
|
+
usedBytes += itemBytes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { items: bounded, byteLimitReached };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ResultPageMetadata {
|
|
53
|
+
start_row: number;
|
|
54
|
+
limit: number;
|
|
55
|
+
returned_count: number;
|
|
56
|
+
total_count?: number;
|
|
57
|
+
has_more: boolean;
|
|
58
|
+
truncated: boolean;
|
|
59
|
+
byte_limit_reached: boolean;
|
|
60
|
+
next_start_row?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resultPageMetadata(input: {
|
|
64
|
+
startRow: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
returnedCount: number;
|
|
67
|
+
totalCount?: number;
|
|
68
|
+
hasMore: boolean;
|
|
69
|
+
byteLimitReached: boolean;
|
|
70
|
+
}): ResultPageMetadata {
|
|
71
|
+
const {
|
|
72
|
+
startRow,
|
|
73
|
+
limit,
|
|
74
|
+
returnedCount,
|
|
75
|
+
totalCount,
|
|
76
|
+
hasMore,
|
|
77
|
+
byteLimitReached,
|
|
78
|
+
} = input;
|
|
79
|
+
return {
|
|
80
|
+
start_row: startRow,
|
|
81
|
+
limit,
|
|
82
|
+
returned_count: returnedCount,
|
|
83
|
+
...(totalCount !== undefined ? { total_count: totalCount } : {}),
|
|
84
|
+
has_more: hasMore,
|
|
85
|
+
truncated: hasMore || byteLimitReached,
|
|
86
|
+
byte_limit_reached: byteLimitReached,
|
|
87
|
+
...(hasMore && returnedCount > 0
|
|
88
|
+
? { next_start_row: startRow + returnedCount }
|
|
89
|
+
: {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
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
|
+
}
|