@digestseo/mcp-gsc 0.4.14 → 0.4.16

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.16] - 2026-09-22
11
+
12
+ ### Fixed
13
+ - High-level Search Analytics helpers now reserve their worst-case upstream request fan-out against the shared local safety budget, so paginated comparison/cannibalization/decay workflows cannot bypass the 10-minute limiter by hiding multiple Google API calls behind one MCP tool call.
14
+ - `indexing.request` now exposes Google's current Indexing API usage boundary: the default 200 publish requests/day/project is onboarding/testing capacity rather than ongoing-use approval, ongoing usage/resource provisioning requires approval, and all submissions are subject to spam detection.
15
+
16
+ ## [0.4.15] - 2026-09-22
17
+
18
+ ### Fixed
19
+ - `analytics.query` now distinguishes fresh preliminary Search Analytics data from finalized-data lag, echoes the requested `data_state`, and reports whether preliminary data is possible instead of implying a blanket 2-3 day delay.
20
+ - Repo-owned quick-connect configs and client docs now use the verified loopback npm launcher instead of routing new users to the intentionally unadvertised hosted remote while that deployment lags the current package.
21
+
10
22
  ## [0.4.14] - 2026-09-22
11
23
 
12
24
  ### Fixed
package/README.md CHANGED
@@ -30,35 +30,37 @@ The local launcher defaults to `GSC_ACCESS_MODE=readwrite`. For least-privilege
30
30
 
31
31
  For Google OAuth and Cloudflare deployment configuration, follow [SETUP.md](SETUP.md).
32
32
 
33
- ### Connect in 30 seconds
33
+ ### Connect the local launcher
34
34
 
35
- Every deployed instance exposes the same endpoint shape:
35
+ For the current verified quick-connect path, start the npm launcher first after supplying the Google OAuth and token-encryption environment variables described in [SETUP.md](SETUP.md):
36
36
 
37
- ```
38
- https://<your-worker>.workers.dev/mcp
37
+ ```bash
38
+ npx -y @digestseo/mcp-gsc
39
39
  ```
40
40
 
41
- The DigestSEO-hosted instance is available at:
41
+ Then connect your MCP client to the launcher's loopback endpoint:
42
42
 
43
43
  ```
44
- https://mcp-gsc.digestseo.com/mcp
44
+ http://127.0.0.1:8080/mcp
45
45
  ```
46
46
 
47
+ Self-hosted Cloudflare Worker deployments continue to use `https://<your-worker>.workers.dev/mcp`. The repo does not currently advertise the owner-hosted remote as a quick-connect path because its deployed tool contract is behind the published package.
48
+
47
49
  **Claude Code**
48
50
 
49
51
  ```bash
50
- claude mcp add --transport http gsc https://<your-worker>.workers.dev/mcp
52
+ claude mcp add --transport http gsc http://127.0.0.1:8080/mcp
51
53
  ```
52
54
 
53
55
  **Cursor**
54
56
 
55
- [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=gsc&config=eyJ1cmwiOiJodHRwczovL21jcC1nc2MuZGlnZXN0c2VvLmNvbS9tY3AifQ%3D%3D)
57
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=gsc&config=eyJ1cmwiOiJodHRwOi8vMTI3LjAuMC4xOjgwODAvbWNwIn0%3D)
56
58
 
57
59
  **Kiro**
58
60
 
59
- [![Add to Kiro](https://kiro.dev/images/add-to-kiro.svg)](https://kiro.dev/launch/mcp/add?name=mcp-gsc&config=%7B%22url%22%3A%22https%3A%2F%2Fmcp-gsc.digestseo.com%2Fmcp%22%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D)
61
+ [![Add to Kiro](https://kiro.dev/images/add-to-kiro.svg)](https://kiro.dev/launch/mcp/add?name=mcp-gsc&config=%7B%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D)
60
62
 
61
- Uses the hosted endpoint above; complete its Google OAuth flow on first use.
63
+ Start the npm launcher before using either install button. Complete its Google OAuth flow on first use.
62
64
 
63
65
  **ChatGPT** — add it as a custom MCP app in developer mode; see [Connect in your AI client](#connect-in-your-ai-client) below.
64
66
 
@@ -77,7 +79,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
77
79
  | **`server.capabilities`** | Read | List every tool this server exposes and report whether your Google connection is currently authenticated (`connected` / `not_connected`). Takes no arguments — a good first call for discovery. |
78
80
  | **`sites.list`** | Read | List the Search Console properties the connected Google account can access (`siteUrl`, `permissionLevel`). |
79
81
  | **`sites.get`** | Read | Retrieve one exact Search Console property and the connected account's permission level for it. |
80
- | **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
82
+ | **`analytics.query`** | Read | Impressions, clicks, CTR, and average position over a date range, with dimensions, filters, safe pagination, and selectable search type. Pass `dimensions: []` for true site totals; Google's aggregate row may omit `keys`. Search Analytics does not guarantee every data row and can return only top rows, so exhausting local pagination is not proof that the provider dataset is exhaustive. `data_state: "all"` can include fresh preliminary data, while `final` returns only finalized data; responses echo `data_state` plus `preliminary_data_possible`, and date/hour groupings preserve Google's incomplete-data boundary metadata when returned. Search appearance discovery is a two-step workflow: group by `searchAppearance` alone to discover the exact values Google returns for the property, then filter to one returned value in a separate query when grouping by page/query/country/device/date/hour. For `search_type: "discover"` or `"googleNews"`, `position_supported` is `false`; those reports expose clicks, impressions, and CTR but not average position, and query grouping/filtering is unavailable. Search Console's dedicated Generative AI performance reports are not exposed by the current documented Search Analytics API as a dedicated search type or filter selector; `generative_ai_report_isolatable` is therefore `false`. |
81
83
  | **`insights.page_queries`** / **`insights.query_pages`** | Read | Drill from one exact page to its Search Console queries, or from one exact query to the pages Google surfaced for it, with `row_limit` / `start_row` pagination. These query-centric helpers support web, image, video, and the Search results News tab; Discover and the Google News report are excluded because they do not expose query data. |
82
84
  | **`urls.inspect`** | Read | Google's URL Inspection report for the version of one page currently known in Google's index. It is not a live URL test; mobile-usability output is deprecated. |
83
85
  | **`urls.inspect_many`** | Read | Inspect Google's indexed versions of up to 10 URLs with bounded concurrency of 3 in one call while preserving input-order results. Each URL still consumes one Google URL Inspection request and one unit of the server's shared inspection safety budget; this does not run live URL tests. |
@@ -87,7 +89,7 @@ By default (`GSC_ACCESS_MODE=readwrite`), this server exposes 21 tools. Read-onl
87
89
  | **`reports.weekly_digest`** | Read | Generate a plain-language seven-day performance report with movers, top pages, and one recommended action. Query movers compare only rows returned in both weekly Search Analytics result sets; a query missing from one bounded response is not treated as zero because Google does not guarantee every data row. |
88
90
  | **`sites.add`** / **`sites.delete`** | Write | Add or remove a Search Console property. `sites.add` only adds the property to the connected account's Search Console site set; it returns `ownership_verification_performed: false` because ownership verification is a separate Google Site Verification/Search Console workflow. |
89
91
  | **`sitemaps.submit`** / **`sitemaps.delete`** | Write | Submit or remove a sitemap. |
90
- | **`indexing.request`** | Write | Requests indexing through Google's Indexing API. Google currently restricts this API to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission. |
92
+ | **`indexing.request`** | Write | Requests indexing through Google's Indexing API. Google currently restricts this API to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission. Google's default 200 publish requests/day/project is onboarding/testing capacity rather than ongoing-use approval; additional approval is required for usage/resource provisioning, and submissions are spam-screened. |
91
93
 
92
94
  > **Platform properties.** Search Console now supports platform properties for social/video accounts such as Instagram, TikTok, X, and YouTube in its UI. Google's current Search Console API documentation still defines `siteUrl` using URL-prefix and `sc-domain:` website-property forms and does not publish a platform-property API identifier contract. `sites.list` therefore preserves every identifier Google returns and marks whether it matches the documented API grammar; the other tools deliberately reject undocumented forms instead of inventing an identifier. Use the Search Console UI/export for a platform property until Google documents API support.
93
95
 
@@ -95,7 +97,7 @@ Read-write mode requests the Google Search Console read-write and Indexing API s
95
97
 
96
98
  Large analytics responses are bounded at the MCP boundary instead of being generated and then discarded by clients with structured-content limits. List-style tools expose `start_row` / `next_start_row` where applicable; ranked analytical tools expose `limit`, `start_row`, and `result_page` metadata including `has_more`, `truncated`, and `byte_limit_reached`. Continue paging while `has_more` is true rather than assuming one response is complete.
97
99
 
98
- > **`indexing.request` eligibility.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks static JSON-LD, Microdata, and RDFa markup for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
100
+ > **`indexing.request` eligibility and provider approval.** Google's Indexing API is not a general-purpose page submission tool — as of this writing, Google's own documentation limits it to two content types: pages with `JobPosting` structured data, and livestream pages with `BroadcastEvent` structured data nested inside `VideoObject`. Before submitting, the server fetches the target URL and checks static JSON-LD, Microdata, and RDFa markup for one of those two types; if neither is present (or the page can't be fetched), it returns an error explaining why the URL is ineligible instead of calling the Indexing API. Structured data injected only after client-side JavaScript runs cannot be confirmed by this bounded preflight and therefore fails closed. Google's default 200 publish requests/day/project is for onboarding and submission testing; using the API beyond that onboarding/testing context requires Google's usage/resource-provisioning approval. Google also subjects every submission to spam detection and warns that abuse or attempts to exceed quotas through multiple accounts or other means can revoke access. A successful submission is only an acknowledgment that Google received the notification — it does not guarantee the URL will be indexed.
99
101
 
100
102
  ## What you can ask
101
103
 
package/SETUP.md CHANGED
@@ -61,7 +61,7 @@ npx wrangler login
61
61
 
62
62
  `readwrite` preserves the full tool surface: it grants Search Console read-write access and the Indexing API scope required to manage sites, sitemaps, and request URL crawling. `readonly` requests only the Search Console read-only scope and omits the mutation tools.
63
63
 
64
- Note that the Indexing API itself is narrow: Google currently restricts it to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission — the `indexing.request` tool checks a page's structured data before submitting and returns an error for ineligible URLs.
64
+ Note that the Indexing API itself is narrow: Google currently restricts it to pages containing `JobPosting` structured data or livestream pages containing `BroadcastEvent` inside `VideoObject`. It is not available for general webpage submission — the `indexing.request` tool checks a page's structured data before submitting and returns an error for ineligible URLs. Google's default **200 publish requests/day/project** is explicitly for onboarding and submission testing; ongoing usage/resource provisioning requires additional Google approval. All submissions are subject to spam detection, and Google warns that abuse or attempts to exceed quotas through multiple accounts or other means can result in revoked access. The server's own lower safety limits do not constitute Google approval or increase provider quota.
65
65
  5. Save the Data Access changes. In **Google Auth platform → Audience**, leave **Publishing status** as **Testing** for now — see [Step 7](#step-7--important-google-verification).
66
66
 
67
67
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digestseo/mcp-gsc",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "private": false,
5
5
  "main": "scripts/glama-start.mjs",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -41,7 +41,11 @@ import {
41
41
  PendingAuthStateStore,
42
42
  stashPendingAuth,
43
43
  } from './pending-auth-state';
44
- import { enforceToolRateLimit, type RateLimitedToolName } from './tool-rate-limit';
44
+ import {
45
+ enforceToolRateLimit,
46
+ getToolRateLimitUnits,
47
+ type RateLimitedToolName,
48
+ } from './tool-rate-limit';
45
49
  export { ToolRateLimiter } from './tool-rate-limiter-do';
46
50
  import { generateWeeklyDigest, resolveWeeklyDigestEndDate } from './digest';
47
51
  import {
@@ -213,6 +217,9 @@ const SITE_DETAIL_OUTPUT_SCHEMA = {
213
217
  const SITE_ADD_VERIFICATION_NOTE =
214
218
  "Google Search Console Sites.add only adds the property to the user's Search Console site set; it does not verify ownership. Ownership verification is a separate Google Site Verification/Search Console workflow.";
215
219
 
220
+ const INDEXING_PROVIDER_USAGE_NOTE =
221
+ "Google describes the Indexing API's default 200 publish-requests-per-day project quota as onboarding/testing capacity, not approval for ongoing usage. Additional approval is required for usage/resource provisioning. Google also applies spam detection to all submissions and warns that abuse or attempts to exceed quotas through multiple accounts or other means can result in revoked access.";
222
+
216
223
  const URL_INSPECTION_SCOPE_NOTE =
217
224
  "Google's URL Inspection API reports the version currently known in Google's index; it does not run a live URL test or prove current live-page indexability. The mobileUsabilityResult field is deprecated and may be absent.";
218
225
 
@@ -244,6 +251,12 @@ const SEARCH_ANALYTICS_OUTPUT_SCHEMA = {
244
251
  row_count: z.number().int().nonnegative(),
245
252
  start_row: z.number().int().nonnegative(),
246
253
  rows: z.array(z.object(SEARCH_ROW_OUTPUT_SCHEMA)),
254
+ data_state: z.enum(['all', 'final', 'hourly_all']).describe(
255
+ 'The Search Analytics dataState requested from Google for this response.',
256
+ ),
257
+ preliminary_data_possible: z.boolean().describe(
258
+ 'True when the requested data state can include fresh data that is still being collected and processed. This does not assert that every returned row is incomplete.',
259
+ ),
247
260
  position_supported: z.boolean().describe(
248
261
  'Whether average position is supported for the selected Search Analytics search type. False for Google Discover and Google News.',
249
262
  ),
@@ -434,6 +447,10 @@ const CONTENT_DECAY_OUTPUT_SCHEMA = {
434
447
  const INDEXING_OUTPUT_SCHEMA = {
435
448
  result: z.unknown(),
436
449
  note: z.string(),
450
+ provider_default_quota_for_testing_only: z.literal(true),
451
+ provider_usage_approval_required: z.literal(true),
452
+ provider_spam_detection_applies: z.literal(true),
453
+ provider_usage_note: z.string(),
437
454
  };
438
455
 
439
456
  const INDEXED_PAGES_OUTPUT_SCHEMA = {
@@ -652,7 +669,7 @@ const TOOL_CATALOG = [
652
669
  {
653
670
  name: 'indexing.request',
654
671
  description:
655
- 'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool.',
672
+ 'Request an eligible JobPosting or livestream URL update through Google\'s restricted Indexing API; this is not a general webpage submission tool. Google treats the default publish quota as onboarding/testing capacity, requires approval for ongoing usage/resource provisioning, and spam-screens submissions.',
656
673
  },
657
674
  {
658
675
  name: 'indexing.list_pages',
@@ -698,7 +715,7 @@ class GscMcpRuntime {
698
715
  private async rateLimitError(
699
716
  googleId: string,
700
717
  toolName: RateLimitedToolName,
701
- units = 1,
718
+ units = getToolRateLimitUnits(toolName),
702
719
  ) {
703
720
  const result = await enforceToolRateLimit(this.env, googleId, toolName, units);
704
721
  if (result.allowed) return null;
@@ -989,10 +1006,13 @@ class GscMcpRuntime {
989
1006
  ' matching dimension. Expect the returned rows to cover only a',
990
1007
  ' subset of total impressions; this is normal Google behavior, not',
991
1008
  ' a data error.',
992
- '- DATA FRESHNESS: Search Console data lags about 2-3 days behind',
993
- ' real time. If the user asks about "today" or "yesterday", expect',
994
- ' empty or partial rows for the most recent days; the latest',
995
- ' reliably-complete date is usually 3 days ago.',
1009
+ '- DATA FRESHNESS: data_state "all" can include fresh preliminary',
1010
+ ' data instead of imposing the finalized-data lag; "final" returns',
1011
+ ' only finalized data and can therefore lag recent activity. When',
1012
+ ' Google returns metadata.first_incomplete_date or',
1013
+ ' metadata.first_incomplete_hour for date/hour groupings, treat',
1014
+ ' that boundary and later rows as still subject to change. The',
1015
+ ' response also echoes data_state and preliminary_data_possible.',
996
1016
  '- AVERAGE POSITION is impression-weighted. To compute an overall',
997
1017
  ' position across multiple rows, use',
998
1018
  ' sum(position * impressions) / sum(impressions). Never plain-average',
@@ -1045,7 +1065,7 @@ class GscMcpRuntime {
1045
1065
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA,
1046
1066
  start_date: SEARCH_CONSOLE_DATE_SCHEMA.describe('Start date (inclusive) in YYYY-MM-DD format.'),
1047
1067
  end_date: SEARCH_CONSOLE_DATE_SCHEMA.describe(
1048
- 'End date (inclusive) in YYYY-MM-DD format. Note the 2-3 day data lag: the most recent complete date is usually 3 days ago.',
1068
+ 'End date (inclusive) in YYYY-MM-DD format. Recent dates are allowed: data_state all/hourly_all can include preliminary data, while final returns only finalized data.',
1049
1069
  ),
1050
1070
  dimensions: z
1051
1071
  .array(
@@ -1173,6 +1193,8 @@ class GscMcpRuntime {
1173
1193
  row_count: bounded.items.length,
1174
1194
  start_row,
1175
1195
  rows: bounded.items,
1196
+ data_state,
1197
+ preliminary_data_possible: data_state !== 'final',
1176
1198
  position_supported:
1177
1199
  search_type !== 'discover' && search_type !== 'googleNews',
1178
1200
  ...(search_type === 'discover' || search_type === 'googleNews'
@@ -1649,7 +1671,7 @@ class GscMcpRuntime {
1649
1671
  'indexing.request',
1650
1672
  {
1651
1673
  title: 'Request Indexing',
1652
- description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. Google currently restricts this API to pages containing JobPosting structured data or livestream pages containing BroadcastEvent inside VideoObject. It is not available for general webpage submission.",
1674
+ description: "Requests indexing through Google's Indexing API for a URL within an owner-level Search Console property accessible to the connected Google account. Google currently restricts this API to pages containing JobPosting structured data or livestream pages containing BroadcastEvent inside VideoObject. It is not available for general webpage submission. Google's default 200 publish-requests-per-day project quota is for onboarding/testing rather than ongoing-use approval; additional approval is required for usage/resource provisioning, and all submissions are subject to spam detection.",
1653
1675
  inputSchema: {
1654
1676
  site_url: SEARCH_CONSOLE_PROPERTY_SCHEMA.describe(`${SEARCH_CONSOLE_PROPERTY_DESCRIPTION} The connected Google account must be an owner of this property.`),
1655
1677
  url: z.string().superRefine((value, ctx) => {
@@ -1677,6 +1699,10 @@ class GscMcpRuntime {
1677
1699
  const payload = {
1678
1700
  result,
1679
1701
  note: "Google accepting this notification does not guarantee the URL will be indexed. Indexing remains at Google's discretion.",
1702
+ provider_default_quota_for_testing_only: true as const,
1703
+ provider_usage_approval_required: true as const,
1704
+ provider_spam_detection_applies: true as const,
1705
+ provider_usage_note: INDEXING_PROVIDER_USAGE_NOTE,
1680
1706
  };
1681
1707
  return toolResponse(JSON.stringify(payload, null, 2), payload);
1682
1708
  },
@@ -60,8 +60,9 @@ export const TOOL_RATE_LIMIT_POLICIES = {
60
60
  indexing_request: {
61
61
  category: 'indexing-request',
62
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.
63
+ // Google's 200 publish/day default is onboarding/testing quota and does
64
+ // not itself authorize ongoing use. Mirror that number only as a local
65
+ // safety ceiling; two per user reserves it for up to 100 users.
65
66
  windowMs: DAY_MS,
66
67
  userLimit: 2,
67
68
  projectLimit: 200,
@@ -79,6 +80,16 @@ export type ToolRateLimitCategory = keyof typeof TOOL_RATE_LIMIT_POLICIES;
79
80
  export type RateLimitedToolName =
80
81
  (typeof TOOL_RATE_LIMIT_POLICIES)[ToolRateLimitCategory]['tools'][number];
81
82
 
83
+ const TOOL_RATE_LIMIT_UNITS: Partial<Record<RateLimitedToolName, number>> = {
84
+ // These helpers can fan one MCP call out into several Search Analytics
85
+ // requests. Reserve the worst-case upstream call count before starting so
86
+ // a single helper cannot bypass the shared 10-minute safety budget.
87
+ 'insights.quick_wins': 4,
88
+ 'insights.cannibalization': 4,
89
+ 'insights.content_decay': 8,
90
+ 'analytics.compare': 8,
91
+ };
92
+
82
93
  const POLICY_BY_TOOL = new Map<string, ToolRateLimitPolicy>(
83
94
  Object.values(TOOL_RATE_LIMIT_POLICIES).flatMap((policy) =>
84
95
  policy.tools.map((tool) => [tool, policy] as const),
@@ -194,6 +205,10 @@ export function getToolRateLimitPolicy(toolName: string): ToolRateLimitPolicy |
194
205
  return POLICY_BY_TOOL.get(toolName);
195
206
  }
196
207
 
208
+ export function getToolRateLimitUnits(toolName: RateLimitedToolName): number {
209
+ return TOOL_RATE_LIMIT_UNITS[toolName] ?? 1;
210
+ }
211
+
197
212
  async function userBucketFor(googleId: string): Promise<string> {
198
213
  const digest = await crypto.subtle.digest(
199
214
  'SHA-256',