@nurkamol/seo-audit 1.32.0 → 1.33.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/README.md CHANGED
@@ -253,7 +253,34 @@ nobody has been shown.
253
253
  Opt-in, and the only thing in this tool that needs an account. It reads
254
254
  `GSC_CLIENT_ID`, `GSC_CLIENT_SECRET` and `GSC_REFRESH_TOKEN` from the
255
255
  environment or from `~/.config/seo-audit/.env`, deliberately outside any
256
- repository. A domain property is named `sc-domain:example.com` rather than by
256
+ repository.
257
+
258
+ Getting the third one used to be left as an exercise, which is why this had
259
+ never run against the live API. Now:
260
+
261
+ ```bash
262
+ # once, in console.cloud.google.com:
263
+ # enable the Search Console API, then create an OAuth client of type
264
+ # "Desktop app", and put its two values in ~/.config/seo-audit/.env
265
+ # GSC_CLIENT_ID=…apps.googleusercontent.com
266
+ # GSC_CLIENT_SECRET=…
267
+
268
+ npx @nurkamol/seo-audit --search-console-login
269
+ ```
270
+
271
+ That opens a browser, you sign in, and the refresh token is written to the same
272
+ file at mode `600`. It is never printed — a token echoed to a terminal is a
273
+ token in a scrollback buffer and probably in a shell history file. The scope is
274
+ read-only. Afterwards it lists the properties the account can actually read,
275
+ because a token that can read nothing looks exactly like one that works, right
276
+ up until an audit reports the property was not found.
277
+
278
+ Interactive by nature, so it is deliberately **not** a GitHub Action input: a
279
+ flag CI can accept and never satisfy is worse than no flag. In CI, set the
280
+ three variables as secrets.
281
+
282
+ The whole setup, the property-naming trap and what each failure note means:
283
+ [docs/search-console.md](docs/search-console.md). A domain property is named `sc-domain:example.com` rather than by
257
284
  its URL. Missing credentials, or a property the account cannot read, are a note
258
285
  and the rest of the audit is unaffected.
259
286
 
package/action.yml CHANGED
@@ -32,6 +32,15 @@ inputs:
32
32
  under it, sampled. Set the PSI_API_KEY env var from a secret to lift the
33
33
  quota.
34
34
  required: false
35
+ search-console:
36
+ description: >
37
+ Order findings by what the pages actually do in Google. The property
38
+ exactly as Search Console names it — a domain property is
39
+ "sc-domain:example.com", not a URL. Needs GSC_CLIENT_ID,
40
+ GSC_CLIENT_SECRET and GSC_REFRESH_TOKEN in the environment, from secrets;
41
+ get them once with --search-console-login on a machine with a browser.
42
+ See docs/search-console.md.
43
+ required: false
35
44
  check-external:
36
45
  description: >
37
46
  Also check links pointing off the site. Off by default because other
@@ -143,6 +152,7 @@ runs:
143
152
  [ -n "$INPUT_LIMIT" ] && args+=(--limit "$INPUT_LIMIT")
144
153
  [ -n "$INPUT_PSI" ] && args+=(--psi "$INPUT_PSI")
145
154
  [ -n "$INPUT_PSI_SAMPLE" ] && args+=(--psi-sample "$INPUT_PSI_SAMPLE")
155
+ [ -n "$INPUT_SEARCH_CONSOLE" ] && args+=(--search-console "$INPUT_SEARCH_CONSOLE")
146
156
  [ -n "$INPUT_VERBOSE" ] && args+=(--verbose)
147
157
  [ -n "$INPUT_CHECK_EXTERNAL" ] && args+=(--check-external)
148
158
  [ -n "$INPUT_SETTLE" ] && args+=(--settle "$INPUT_SETTLE")
package/bin/seo-audit.mjs CHANGED
@@ -82,6 +82,11 @@ const HELP = `
82
82
  GSC_REFRESH_TOKEN in the environment or in
83
83
  ~/.config/seo-audit/.env. A domain property is named
84
84
  "sc-domain:example.com" rather than by its URL
85
+ --search-console-login
86
+ sign in to Google once and write GSC_REFRESH_TOKEN to
87
+ ~/.config/seo-audit/.env. Needs GSC_CLIENT_ID and
88
+ GSC_CLIENT_SECRET there first, from an OAuth client of
89
+ type "Desktop app". Opens a browser; not for CI
85
90
  --compare-as <name> fetch a sample of pages a second time as this browser
86
91
  or crawler and report what changed. A page that differs
87
92
  with the reader is cloaking, or bot protection misfiring
@@ -152,6 +157,7 @@ function parseArgs(argv) {
152
157
  const next = argv[i + 1];
153
158
  opts.serve = next && /^\d+$/.test(next) ? Number(argv[++i]) : true;
154
159
  }
160
+ else if (arg === '--search-console-login') opts.searchConsoleLogin = true;
155
161
  else if (arg === '--search-console') {
156
162
  // Optionally the property name, since a domain property is not a URL.
157
163
  const next = argv[i + 1];
@@ -316,6 +322,37 @@ if (opts.serve !== undefined) {
316
322
  }
317
323
  } else {
318
324
 
325
+ // --- sign in, and stop ----------------------------------------------------
326
+ // Before anything that needs a URL: this takes none. It is the one thing here
327
+ // that is interactive by nature, and the reason `--search-console` had never
328
+ // run against the live API — the three variables were documented and there was
329
+ // no way to obtain the third.
330
+ if (opts.searchConsoleLogin) {
331
+ const { login } = await import('../src/console.mjs');
332
+ try {
333
+ const { dotfile, properties } = await login({
334
+ onNote: (note) => process.stderr.write(` ${note}\n`),
335
+ });
336
+ process.stdout.write(`\n Refresh token written to ${dotfile}\n`);
337
+ if (properties.length) {
338
+ process.stdout.write('\n Properties this account can read:\n');
339
+ for (const p of properties) process.stdout.write(` ${p.url} (${p.permission})\n`);
340
+ process.stdout.write(`\n Try: seo-audit https://example.com --search-console ${properties[0].url}\n\n`);
341
+ } else {
342
+ // A token that can read nothing looks exactly like one that works, right
343
+ // up until an audit reports the property was not found.
344
+ process.stdout.write(
345
+ '\n This account can read no properties. Verify the site in Search Console first,\n' +
346
+ ' or sign in as the account that already has it.\n\n',
347
+ );
348
+ }
349
+ process.exit(properties.length ? 0 : 1);
350
+ } catch (err) {
351
+ process.stderr.write(`\n ${err.message}\n\n`);
352
+ process.exit(1);
353
+ }
354
+ }
355
+
319
356
  // Nothing to audit. If a person is there to ask, ask; otherwise this is a
320
357
  // script or a CI runner and the help text is the right answer.
321
358
  if (!sites.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurkamol/seo-audit",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -22,6 +22,34 @@
22
22
  // ]
23
23
  // }
24
24
  import { readFileSync, existsSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ /**
29
+ * One secret, environment first, then `~/.config/seo-audit/.env`.
30
+ *
31
+ * Deliberately outside this repository, which is public. There were two copies
32
+ * of this — one in `psi.mjs`, one in `console.mjs` — and the second built its
33
+ * pattern with `new RegExp` and a template literal, where `\\\\s` survives as an
34
+ * escaped backslash rather than as whitespace. It compiled, it never threw, and
35
+ * it could not match a single line of a real `.env`. Search Console's dotfile
36
+ * fallback had therefore never worked, and nothing said so because the only
37
+ * tests it had used a fake API and injected credentials.
38
+ */
39
+ export function readSecret(name, env = process.env, read = readFileSync) {
40
+ if (env[name]) return env[name];
41
+ let text;
42
+ try {
43
+ // A missing file and an unreadable one are the same answer, and catching
44
+ // beats an `existsSync` guard: it is one syscall rather than two, it closes
45
+ // the gap between the two calls, and it lets a test hand in its own reader
46
+ // without the real filesystem deciding whether the test runs.
47
+ text = read(join(homedir(), '.config', 'seo-audit', '.env'), 'utf8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ return text.match(new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm'))?.[1] ?? null;
52
+ }
25
53
 
26
54
  const FILENAMES = ['seo-audit.config.json', '.seo-audit.json'];
27
55
 
package/src/console.mjs CHANGED
@@ -9,23 +9,17 @@
9
9
  // Opt-in, and the only thing in this tool that needs an account. Credentials
10
10
  // are read the way the PageSpeed key is — the environment first, then
11
11
  // ~/.config/seo-audit/.env — and never from the repository.
12
- import { readFileSync, existsSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
- import { join } from 'node:path';
12
+ import { readSecret } from './config.mjs';
15
13
 
16
14
  const TOKEN_URL = 'https://oauth2.googleapis.com/token';
17
15
  const API = 'https://searchconsole.googleapis.com/webmasters/v3/sites';
18
16
 
19
17
  const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
20
18
 
21
- /** One credential, environment first. */
22
- export function findCredential(name, env = process.env, read = readFileSync) {
23
- if (env[name]) return env[name];
24
- const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
25
- if (!existsSync(dotfile)) return null;
26
- const match = read(dotfile, 'utf8').match(new RegExp(`^\\\\s*${name}\\\\s*=\\\\s*(\\\\S+)`, 'm'));
27
- return match?.[1] ?? null;
28
- }
19
+ /** One credential, environment first. The loader is shared with the PageSpeed
20
+ * key: this file had its own copy, and its copy could not read the dotfile at
21
+ * all. */
22
+ export const findCredential = readSecret;
29
23
 
30
24
  /** All three, or a sentence saying which is missing. */
31
25
  export function credentials(env = process.env) {
@@ -136,11 +130,212 @@ export async function searchConsole(origin, findings, opts = {}) {
136
130
  matched++;
137
131
  }
138
132
 
139
- const shown = [...traffic.values()].reduce((n, t) => n + t.impressions, 0);
133
+ // Impressions on the pages this crawl actually reached, counted once per
134
+ // page. The first live run summed every row in the property instead — the
135
+ // report said one finding had been "shown 98 times between them" when its
136
+ // page had 13, because the other 85 were on pages the crawl never touched.
137
+ // Both numbers are worth having; they are two different sentences.
138
+ const counted = new Set();
139
+ let shown = 0;
140
+ for (const finding of findings) {
141
+ if (!finding.traffic || counted.has(finding.url)) continue;
142
+ counted.add(finding.url);
143
+ shown += finding.traffic.impressions;
144
+ }
145
+ const everywhere = [...traffic.values()].reduce((n, t) => n + t.impressions, 0);
146
+
140
147
  return [
141
148
  f('info', 'search-console', `Search Console has ${traffic.size.toLocaleString()} pages for this site`,
142
- `${matched.toLocaleString()} of this crawl's findings are on pages Google has shown, ` +
143
- `${shown.toLocaleString()} times between them over 28 days. Findings are ordered by that where it ` +
149
+ `${matched.toLocaleString()} of this crawl's findings ${matched === 1 ? 'is' : 'are'} on ` +
150
+ `${counted.size.toLocaleString()} page${counted.size === 1 ? '' : 's'} Google has shown, ` +
151
+ `${shown.toLocaleString()} time${shown === 1 ? '' : 's'} over 28 days — out of ` +
152
+ `${everywhere.toLocaleString()} across the whole property. Findings are ordered by that where it ` +
144
153
  'is known, and by how much of the site links to a page where it is not.', origin),
145
154
  ];
146
155
  }
156
+
157
+ // --- Getting a refresh token ------------------------------------------------
158
+ //
159
+ // The three variables above were documented for a year and there was never a
160
+ // way to obtain the third one. That is why `--search-console` has never run
161
+ // against the live API: not the code, the paperwork in front of it.
162
+ //
163
+ // Loopback OAuth, which is what Google's own docs call the installed-app flow.
164
+ // A desktop client may redirect to any port on 127.0.0.1 without registering
165
+ // it, so this listens on an ephemeral one, and the browser does the signing in.
166
+ // The token is written to the same file the key lives in and is never printed:
167
+ // a refresh token in a terminal is a refresh token in a scrollback buffer.
168
+
169
+ import { createServer } from 'node:http';
170
+ import { spawn } from 'node:child_process';
171
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
172
+ import { homedir } from 'node:os';
173
+ import { join, dirname } from 'node:path';
174
+ import { randomBytes } from 'node:crypto';
175
+
176
+ const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
177
+ const SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
178
+
179
+ export const DOTFILE = join(homedir(), '.config', 'seo-audit', '.env');
180
+
181
+ /** Where the browser is sent. `prompt=consent` is not politeness: without it
182
+ * Google returns no refresh token at all on a second authorisation, which is
183
+ * the confusing half of this flow. Read-only scope, because this only ever
184
+ * reads. */
185
+ export function authUrl({ clientId, redirectUri, state }) {
186
+ const params = new URLSearchParams({
187
+ client_id: clientId,
188
+ redirect_uri: redirectUri,
189
+ response_type: 'code',
190
+ scope: SCOPE,
191
+ access_type: 'offline',
192
+ prompt: 'consent',
193
+ state,
194
+ });
195
+ return `${AUTH_URL}?${params}`;
196
+ }
197
+
198
+ /** The code the browser came back with, traded for the long-lived half. */
199
+ export async function exchangeCode({ clientId, clientSecret, code, redirectUri }, fetcher = fetch) {
200
+ const res = await fetcher(TOKEN_URL, {
201
+ method: 'POST',
202
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
203
+ body: new URLSearchParams({
204
+ client_id: clientId,
205
+ client_secret: clientSecret,
206
+ code,
207
+ redirect_uri: redirectUri,
208
+ grant_type: 'authorization_code',
209
+ }),
210
+ });
211
+ const data = await res.json();
212
+ if (!res.ok || !data.refresh_token) {
213
+ throw new Error(
214
+ data.error_description ??
215
+ data.error ??
216
+ 'Google returned no refresh token. This happens when the account has authorised this ' +
217
+ 'client before — revoke it at myaccount.google.com/permissions and try again.',
218
+ );
219
+ }
220
+ return data;
221
+ }
222
+
223
+ /** What this account can actually read. Printed after a login because a token
224
+ * that works for nothing looks exactly like a token that works. */
225
+ export async function listProperties(token, fetcher = fetch) {
226
+ const res = await fetcher(API, { headers: { authorization: `Bearer ${token}` } });
227
+ const data = await res.json();
228
+ if (!res.ok) throw new Error(data.error?.message ?? `HTTP ${res.status} listing properties`);
229
+ return (data.siteEntry ?? []).map((s) => ({ url: s.siteUrl, permission: s.permissionLevel }));
230
+ }
231
+
232
+ /** One line rewritten, the rest of the file untouched.
233
+ *
234
+ * Kept pure and exported so it can be tested: this writes to the file holding
235
+ * somebody's PageSpeed key, and clobbering that to save a Search Console token
236
+ * would be a poor trade. */
237
+ export function upsertSecret(text, name, value) {
238
+ const line = `${name}=${value}`;
239
+ const pattern = new RegExp(`^\\s*${name}\\s*=.*$`, 'm');
240
+ if (pattern.test(text)) return text.replace(pattern, line);
241
+ return text.length && !text.endsWith('\n') ? `${text}\n${line}\n` : `${text}${line}\n`;
242
+ }
243
+
244
+ function openBrowser(url) {
245
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
246
+ try {
247
+ spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ const donePage = (heading, body) =>
255
+ `<!doctype html><meta charset="utf-8"><title>${heading}</title>` +
256
+ '<style>body{font:16px/1.6 -apple-system,system-ui,sans-serif;margin:20vh auto;max-width:32rem;' +
257
+ 'padding:0 1.5rem;color:#111}h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#555;margin:0}' +
258
+ '@media(prefers-color-scheme:dark){body{background:#111;color:#eee}p{color:#aaa}}</style>' +
259
+ `<h1>${heading}</h1><p>${body}</p>`;
260
+
261
+ /**
262
+ * Sign in once, and write the refresh token where the audit will look for it.
263
+ *
264
+ * Interactive by nature, which is why it is not an Action input: a flag CI can
265
+ * accept and never satisfy is worse than no flag. The pieces that can be wrong
266
+ * quietly — the authorisation URL, the token exchange, rewriting a file that
267
+ * already holds somebody's PageSpeed key — are separate exported functions with
268
+ * tests. What is left here is a socket and a browser.
269
+ */
270
+ export async function login({
271
+ fetcher = fetch,
272
+ openUrl = openBrowser,
273
+ onNote = () => {},
274
+ dotfile = DOTFILE,
275
+ timeout = 300_000,
276
+ // Injectable for the same reason the certificate reader is: otherwise the
277
+ // only way to exercise this is to have real credentials on the machine, and
278
+ // a test that needs those is a test nobody runs.
279
+ client,
280
+ } = {}) {
281
+ const clientId = client?.clientId ?? readSecret('GSC_CLIENT_ID');
282
+ const clientSecret = client?.clientSecret ?? readSecret('GSC_CLIENT_SECRET');
283
+ if (!clientId || !clientSecret) {
284
+ throw new Error(
285
+ 'No OAuth client yet. In console.cloud.google.com: enable the Search Console API, then ' +
286
+ 'create an OAuth client of type "Desktop app". Put its two values in ' +
287
+ `${dotfile} as GSC_CLIENT_ID and GSC_CLIENT_SECRET, and run this again.`,
288
+ );
289
+ }
290
+
291
+ const state = randomBytes(16).toString('hex');
292
+ const server = createServer();
293
+ // Port 0: a desktop client may redirect to any port on the loopback address
294
+ // without registering it, so nothing here has to be configured or be free.
295
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
296
+ const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
297
+
298
+ const code = await new Promise((resolve, reject) => {
299
+ const timer = setTimeout(
300
+ () => reject(new Error(`Nothing came back within ${Math.round(timeout / 1000)}s. Nothing was written.`)),
301
+ timeout,
302
+ );
303
+ server.on('request', (req, res) => {
304
+ const asked = new URL(req.url, redirectUri);
305
+ if (asked.pathname !== '/callback') {
306
+ res.writeHead(404).end();
307
+ return;
308
+ }
309
+ const returned = asked.searchParams.get('code');
310
+ const failed = asked.searchParams.get('error');
311
+ // The state is the only thing standing between this and a page on the
312
+ // internet quietly posting a code to a port on the machine.
313
+ const mismatched = asked.searchParams.get('state') !== state;
314
+ res.writeHead(failed || !returned || mismatched ? 400 : 200, { 'content-type': 'text/html; charset=utf-8' });
315
+ if (failed || !returned || mismatched) {
316
+ res.end(donePage('That did not work', 'Nothing was written. The terminal has the detail.'));
317
+ clearTimeout(timer);
318
+ reject(new Error(mismatched ? 'The reply did not match the request this started.' : (failed ?? 'No code came back.')));
319
+ return;
320
+ }
321
+ res.end(donePage('Signed in', 'You can close this tab and go back to the terminal.'));
322
+ clearTimeout(timer);
323
+ resolve(returned);
324
+ });
325
+
326
+ const url = authUrl({ clientId, redirectUri, state });
327
+ onNote(openUrl(url) ? `waiting on ${redirectUri}` : `open this and sign in:\n\n ${url}\n`);
328
+ }).finally(() => server.close());
329
+
330
+ const granted = await exchangeCode({ clientId, clientSecret, code, redirectUri }, fetcher);
331
+
332
+ mkdirSync(dirname(dotfile), { recursive: true });
333
+ const existing = existsSync(dotfile) ? readFileSync(dotfile, 'utf8') : '';
334
+ // 0600, and never printed: a refresh token echoed to a terminal is a refresh
335
+ // token in a scrollback buffer and probably in a shell history file.
336
+ writeFileSync(dotfile, upsertSecret(existing, 'GSC_REFRESH_TOKEN', granted.refresh_token), { mode: 0o600 });
337
+
338
+ // A token that can read nothing looks exactly like a token that works, until
339
+ // an audit says the property was not found.
340
+ return { dotfile, properties: await listProperties(granted.access_token, fetcher) };
341
+ }
package/src/options.mjs CHANGED
@@ -30,6 +30,7 @@ export const OPTIONS = [
30
30
  { flag: '--browser', query: 'browser', app: true },
31
31
  { flag: '--os', query: 'os', app: true },
32
32
  { flag: '--user-agent', query: 'userAgent', app: true },
33
+ { flag: '--search-console', query: 'search-console', app: true },
33
34
  { flag: '--write-sitemap', query: 'sitemap-out', app: true, via: 'the Export menu' },
34
35
  { flag: '--ignore', query: 'ignore', app: true, via: 'right-clicking a finding, and the Settings list' },
35
36
  { flag: '--psi', query: 'psi', app: true, via: 'Settings → Performance' },
@@ -59,7 +60,7 @@ export const OPTIONS = [
59
60
 
60
61
 
61
62
  // --- not yet, and that is a decision rather than an oversight ------------
62
- { flag: '--search-console', query: null, app: 'not yet needs an OAuth client, and has never run against the live API' },
63
+ { flag: '--search-console-login', query: null, app: 'noit opens a browser and writes a credential to disk, which is a terminal errand, not a control in a window' },
63
64
  { flag: '--against', query: null, app: 'not yet — the window compares two kept runs instead of two live deployments' },
64
65
  { flag: '--compare-as', query: null, app: 'not yet — it fetches a sample of pages a second time, and the window has no control for spending that' },
65
66
  { flag: '--compare-sample', query: null, app: 'not yet — see --compare-as' },
package/src/psi.mjs CHANGED
@@ -11,10 +11,7 @@
11
11
  //
12
12
  // A key is optional but raises the quota well above the anonymous limit. Set
13
13
  // PSI_API_KEY, or put it in ~/.config/seo-audit/.env — never in the repo.
14
- import { readFileSync, existsSync } from 'node:fs';
15
- import { homedir } from 'node:os';
16
- import { join } from 'node:path';
17
- import { matchGlob } from './config.mjs';
14
+ import { matchGlob, readSecret } from './config.mjs';
18
15
 
19
16
  const ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
20
17
 
@@ -34,11 +31,9 @@ const CWV = {
34
31
  };
35
32
 
36
33
  export function findKey() {
37
- if (process.env.PSI_API_KEY) return process.env.PSI_API_KEY;
38
- const dotfile = join(homedir(), '.config', 'seo-audit', '.env');
39
- if (!existsSync(dotfile)) return null;
40
- const match = readFileSync(dotfile, 'utf8').match(/^\s*PSI_API_KEY\s*=\s*(\S+)/m);
41
- return match?.[1] ?? null;
34
+ // Shared with the Search Console credentials rather than copied. The copy
35
+ // this file did not have was broken for months without anything noticing.
36
+ return readSecret('PSI_API_KEY');
42
37
  }
43
38
 
44
39
  async function run(url, strategy, key) {
package/src/serve.mjs CHANGED
@@ -38,6 +38,11 @@ export async function serve({ port = 4321, host = '127.0.0.1', maxPages, allowed
38
38
  // this unset, where a stranger passing ?psi= would be spending somebody
39
39
  // else's.
40
40
  ALLOW_PSI: '1',
41
+ // Same reasoning, sharper stakes: these credentials read somebody's Search
42
+ // Console. On the loopback address the person running the server is the
43
+ // person whose account it is; a deployed Worker leaves this unset, where
44
+ // `?search-console=` would hand a stranger somebody else's traffic data.
45
+ ALLOW_SEARCH_CONSOLE: '1',
41
46
  };
42
47
 
43
48
  const server = createServer(async (incoming, outgoing) => {