@getvitops/utils 6.0.0 → 7.0.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/CHANGELOG.md +117 -0
- package/dist/index.d.mts +90 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +96 -1
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +25 -4
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +49 -9
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +7 -7
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +13 -22
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/{token--18wN9UR.d.mts → token-BlShEM6J.d.mts} +29 -2
- package/dist/{token--18wN9UR.d.mts.map → token-BlShEM6J.d.mts.map} +1 -1
- package/dist/{token-BV7tPHh2.mjs → token-CuyjDo6I.mjs} +11 -2
- package/dist/{token-BV7tPHh2.mjs.map → token-CuyjDo6I.mjs.map} +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n /** The sitemap as it stands now. */\n current: SitemapEntry[];\n /** The previous run's state, if any. */\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The foreign-sitemap check.\n *\n * A 404 fails loudly; a sitemap that is well-formed and *someone else's* does\n * not. A downstream site's sitemap route collided with its CMS's, so the URL\n * returned 200 with a valid document listing pages that were not theirs, and\n * the run reported a healthy cold submission. Nothing downstream can catch\n * this — the URLs parse, the diff works, the submission succeeds.\n *\n * Compared against the resolved environment origin rather than the canonical,\n * for the same reason `toIndexingConfig` derives the origin that way: on a\n * staging run the canonical IS the foreign host.\n */\n if (config.canonical && urls.length) {\n const own = hostOf(`${trimSlash(config.canonical)}/`);\n const foreign = [...new Set(urls.map(hostOf).filter((h): h is string => !!h && h !== own))];\n if (foreign.length)\n notes.push(\n `sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ` +\n `${foreign.join(', ')} rather than ${own} — check that ${sitemapUrl} is this site's own ` +\n `sitemap and not another route answering that path.`,\n );\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { SitemapEntry, Snapshot } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as Snapshot;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\\n`);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport { googleAccessToken, SCOPES, type ServiceAccount } from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n token: string,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n token: string,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts","../../src/google/adc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n /** The sitemap as it stands now. */\n current: SitemapEntry[];\n /** The previous run's state, if any. */\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The foreign-sitemap check.\n *\n * A 404 fails loudly; a sitemap that is well-formed and *someone else's* does\n * not. A downstream site's sitemap route collided with its CMS's, so the URL\n * returned 200 with a valid document listing pages that were not theirs, and\n * the run reported a healthy cold submission. Nothing downstream can catch\n * this — the URLs parse, the diff works, the submission succeeds.\n *\n * Compared against the resolved environment origin rather than the canonical,\n * for the same reason `toIndexingConfig` derives the origin that way: on a\n * staging run the canonical IS the foreign host.\n */\n if (config.canonical && urls.length) {\n const own = hostOf(`${trimSlash(config.canonical)}/`);\n const foreign = [...new Set(urls.map(hostOf).filter((h): h is string => !!h && h !== own))];\n if (foreign.length)\n notes.push(\n `sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ` +\n `${foreign.join(', ')} rather than ${own} — check that ${sitemapUrl} is this site's own ` +\n `sitemap and not another route answering that path.`,\n );\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n // `&` last: decoding it first would let `&lt;` collapse to `<`.\n .replace(/&/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { SitemapEntry, Snapshot } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as Snapshot;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\\n`);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport {\n googleAccessToken,\n googleHeaders,\n SCOPES,\n type GoogleAuthLike,\n type ServiceAccount,\n} from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n auth: GoogleAuthLike,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n auth: GoogleAuthLike,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n","/**\n * Application Default Credentials — recognising a credential gcloud already wrote.\n *\n * `gcloud auth application-default login` writes\n * `{ client_id, client_secret, refresh_token, type: \"authorized_user\" }`, which maps\n * field-for-field onto the refresh-token grant this package already performs. So the\n * only thing that ever stood between \"I'm logged in to gcloud\" and `vitops search`\n * was recognising the shape.\n *\n * Pure, and here rather than in the CLI, for the reason the consent store and the\n * indexing planner are: the decidable part (is this a user credential? where would\n * gcloud have put it?) is worth asserting without a filesystem, and the CLI keeps\n * only the `readFileSync`. `type` is the discriminator Google's own libraries switch\n * on — never the presence of fields, since a service-account JSON also has a `type`.\n */\nimport type { GoogleOAuth } from './token.ts';\n\n/** A parsed ADC user credential, or `undefined` if `raw` is not one. */\nexport function parseAdcUser(raw: string): GoogleOAuth | undefined {\n let parsed: {\n type?: string;\n client_id?: string;\n client_secret?: string;\n refresh_token?: string;\n };\n try {\n parsed = JSON.parse(raw) as typeof parsed;\n } catch {\n return undefined;\n }\n if (parsed.type !== 'authorized_user') return undefined;\n // A truncated or half-written file is not a credential. Returning a partial one\n // would defer the failure to a token exchange whose error names the wrong cause.\n if (!parsed.client_id || !parsed.client_secret || !parsed.refresh_token) return undefined;\n return {\n clientId: parsed.client_id,\n clientSecret: parsed.client_secret,\n refreshToken: parsed.refresh_token,\n };\n}\n\n/** The quota project gcloud recorded with an ADC credential, if any. */\nexport function adcQuotaProject(raw: string): string | undefined {\n try {\n return (JSON.parse(raw) as { quota_project_id?: string }).quota_project_id || undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Where gcloud keeps ADC when nothing points at it explicitly.\n *\n * Reading the well-known path is what makes \"I'm logged in\" mean something, instead\n * of asking the user to export a variable at a file gcloud already wrote.\n * `CLOUDSDK_CONFIG` wins if set, as it does for gcloud itself.\n *\n * Every input is a parameter so the platform branches are testable on one machine.\n */\nexport function adcCredentialsPath(opts: {\n env: Record<string, string | undefined>;\n platform: string;\n home: string | undefined;\n join: (...parts: string[]) => string;\n}): string | undefined {\n const { env, platform, home, join } = opts;\n const sdk = env.CLOUDSDK_CONFIG;\n if (sdk) return join(sdk, 'application_default_credentials.json');\n if (platform === 'win32') {\n return env.APPDATA\n ? join(env.APPDATA, 'gcloud', 'application_default_credentials.json')\n : undefined;\n }\n return home ? join(home, '.config', 'gcloud', 'application_default_credentials.json') : undefined;\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BiK61Oq4.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { i as GoogleOAuth, n as GoogleAuthLike } from "../token-BlShEM6J.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/onboarding/types.d.ts
|
|
5
5
|
/**
|
|
@@ -142,7 +142,7 @@ declare function refreshTokenGrant(oauth: GoogleOAuth, fetchImpl?: typeof fetch)
|
|
|
142
142
|
* record. It is deterministic per (user, domain), so re-fetching it and comparing
|
|
143
143
|
* against DNS is what makes the TXT step idempotent.
|
|
144
144
|
*/
|
|
145
|
-
declare function getVerificationToken(
|
|
145
|
+
declare function getVerificationToken(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
146
146
|
ok: boolean;
|
|
147
147
|
status: number;
|
|
148
148
|
token?: string;
|
|
@@ -156,7 +156,7 @@ declare function getVerificationToken(token: string, domain: string, fetchImpl?:
|
|
|
156
156
|
* contains resources the user already owns — so a hit *is* the verified state, and
|
|
157
157
|
* carries the id and owners the later steps need.
|
|
158
158
|
*/
|
|
159
|
-
declare function getWebResource(
|
|
159
|
+
declare function getWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
160
160
|
ok: boolean;
|
|
161
161
|
status: number;
|
|
162
162
|
exists: boolean;
|
|
@@ -165,7 +165,7 @@ declare function getWebResource(token: string, domain: string, fetchImpl?: typeo
|
|
|
165
165
|
message?: string;
|
|
166
166
|
}>;
|
|
167
167
|
/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
|
|
168
|
-
declare function verifyWebResource(
|
|
168
|
+
declare function verifyWebResource(auth: GoogleAuthLike, domain: string, fetchImpl?: typeof fetch): Promise<{
|
|
169
169
|
ok: boolean;
|
|
170
170
|
status: number;
|
|
171
171
|
id?: string;
|
|
@@ -179,13 +179,13 @@ declare function verifyWebResource(token: string, domain: string, fetchImpl?: ty
|
|
|
179
179
|
* current owners and the ones to add — never a bare list of new owners, which
|
|
180
180
|
* would drop everyone already there.
|
|
181
181
|
*/
|
|
182
|
-
declare function updateOwners(
|
|
182
|
+
declare function updateOwners(auth: GoogleAuthLike, id: string, domain: string, owners: string[], fetchImpl?: typeof fetch): Promise<{
|
|
183
183
|
ok: boolean;
|
|
184
184
|
status: number;
|
|
185
185
|
message?: string;
|
|
186
186
|
}>;
|
|
187
187
|
/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
|
|
188
|
-
declare function getSite(
|
|
188
|
+
declare function getSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
189
189
|
ok: boolean;
|
|
190
190
|
status: number;
|
|
191
191
|
exists: boolean;
|
|
@@ -197,7 +197,7 @@ declare function getSite(token: string, siteUrl: string, fetchImpl?: typeof fetc
|
|
|
197
197
|
* A `403` here almost always means the domain is not yet verified for this user
|
|
198
198
|
* rather than a transient error — property creation is gated on ownership.
|
|
199
199
|
*/
|
|
200
|
-
declare function addSite(
|
|
200
|
+
declare function addSite(auth: GoogleAuthLike, siteUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
201
201
|
ok: boolean;
|
|
202
202
|
status: number;
|
|
203
203
|
message?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/types.ts","../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"mappings":";;;;;;;;AAeA;;;;;;;;AAYe;AAIf;AAAA,UAhBiB,WAAA;;EAEf,MAAA;EAgBoB;AAiBtB;;;EA5BE,eAAA;EA8BA;;;;EAzBA,aAAA;AAAA;AAiCc;AAAA,UA7BC,gBAAA;;EAEf,OAAA,EAAS,WAAW;AAAA;;;;;;;ACZd;UD6BS,WAAA;ECzBU;ED2BzB,MAAA;ECtBK;EDwBL,UAAA;ECpBU;EDsBV,QAAA;ECpBgB;EDsBhB,aAAA;EChCA;EDkCA,cAAA;AAAA;;;;KC5CU,UAAA;AAAA,UAEK,QAAA;EACf,MAAA,EAAQ,UAAU;ED+BH;EC7Bf,MAAA;AAAA;;UAIe,UAAA;EACf,MAAA;ED8BA;EC5BA,OAAA;EDgCA;EC9BA,GAAA,EAAK,QAAA;ED8BS;EC5Bd,MAAA,EAAQ,QAAA;;EAER,QAAA,EAAU,QAAA;EAlBA;EAoBV,MAAA,EAAQ,QAAA;;EAER,WAAA;EAtBoB;EAwBpB,SAAA;AAAA;AAAA,UAGe,cAAA;EACf,OAAA,EAAS,UAAU;EAzBnB;EA2BA,KAAA;AAAA;;cAIW,UAAA,GAAc,MAAc;AAzBzC;;;;;;;;;AAAA,iBAoCgB,eAAA,CAAgB,WAAe;;iBAO/B,UAAA,CAAW,KAAA,EAAO,WAAA,EAAa,KAAA,EAAO,WAAA,GAAc,UAAA;;;;;;;;;;;AA5BzD;AAGX;;;iBAqFgB,UAAA,CAAW,OAAA,YAAmB,KAAe;;iBAa7C,IAAA,CAAK,MAAA,EAAQ,gBAAA,EAAkB,MAAA,EAAQ,GAAA,SAAY,WAAA,IAAe,cAAA;;iBAiBlE,QAAA,CAAS,CAAiB,EAAd,cAAc;AAhHnC;AAAA,iBA2HS,UAAA,CAAW,CAAiB,EAAd,cAAc;;UAgB3B,YAAA;EACf,MAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,SAAA;AAAA;AAjI6C;AAAA,iBAuI/B,aAAA,CAAc,OAAuB,EAAd,YAAY;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/onboarding/types.ts","../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"mappings":";;;;;;;;AAeA;;;;;;;;AAYe;AAIf;AAAA,UAhBiB,WAAA;;EAEf,MAAA;EAgBoB;AAiBtB;;;EA5BE,eAAA;EA8BA;;;;EAzBA,aAAA;AAAA;AAiCc;AAAA,UA7BC,gBAAA;;EAEf,OAAA,EAAS,WAAW;AAAA;;;;;;;ACZd;UD6BS,WAAA;ECzBU;ED2BzB,MAAA;ECtBK;EDwBL,UAAA;ECpBU;EDsBV,QAAA;ECpBgB;EDsBhB,aAAA;EChCA;EDkCA,cAAA;AAAA;;;;KC5CU,UAAA;AAAA,UAEK,QAAA;EACf,MAAA,EAAQ,UAAU;ED+BH;EC7Bf,MAAA;AAAA;;UAIe,UAAA;EACf,MAAA;ED8BA;EC5BA,OAAA;EDgCA;EC9BA,GAAA,EAAK,QAAA;ED8BS;EC5Bd,MAAA,EAAQ,QAAA;;EAER,QAAA,EAAU,QAAA;EAlBA;EAoBV,MAAA,EAAQ,QAAA;;EAER,WAAA;EAtBoB;EAwBpB,SAAA;AAAA;AAAA,UAGe,cAAA;EACf,OAAA,EAAS,UAAU;EAzBnB;EA2BA,KAAA;AAAA;;cAIW,UAAA,GAAc,MAAc;AAzBzC;;;;;;;;;AAAA,iBAoCgB,eAAA,CAAgB,WAAe;;iBAO/B,UAAA,CAAW,KAAA,EAAO,WAAA,EAAa,KAAA,EAAO,WAAA,GAAc,UAAA;;;;;;;;;;;AA5BzD;AAGX;;;iBAqFgB,UAAA,CAAW,OAAA,YAAmB,KAAe;;iBAa7C,IAAA,CAAK,MAAA,EAAQ,gBAAA,EAAkB,MAAA,EAAQ,GAAA,SAAY,WAAA,IAAe,cAAA;;iBAiBlE,QAAA,CAAS,CAAiB,EAAd,cAAc;AAhHnC;AAAA,iBA2HS,UAAA,CAAW,CAAiB,EAAd,cAAc;;UAgB3B,YAAA;EACf,MAAA;EACA,GAAA;EACA,QAAA;EACA,QAAA;EACA,SAAA;AAAA;AAjI6C;AAAA,iBAuI/B,aAAA,CAAc,OAAuB,EAAd,YAAY;;;;iBCvKnC,iBAAA,CACd,KAAA,EAAO,WAAA,EACP,SAAA,UAAkB,KAAA,GACjB,OAAA;;;;;;;;iBAamB,oBAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,KAAA;EAAgB,OAAA;AAAA;;;ADjCpC;AAEtB;;;;;iBCmDsB,cAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EACD,EAAA;EACA,MAAA;EACA,MAAA;EACA,EAAA;EACA,MAAA;EACA,OAAA;AAAA;;iBAwBoB,iBAAA,CACpB,IAAA,EAAM,cAAA,EACN,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,EAAA;EAAa,MAAA;EAAkB,OAAA;AAAA;;;;;;;;iBAuBnD,YAAA,CACpB,IAAA,EAAM,cAAA,EACN,EAAA,UACA,MAAA,UACA,MAAA,YACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA;;iBAapB,OAAA,CACpB,IAAA,EAAM,cAAA,EACN,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,MAAA;EAAiB,OAAA;AAAA;AD3GpD;AAIP;;;;AAAyC;AAJlC,iBC0He,OAAA,CACpB,IAAA,EAAM,cAAA,EACN,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as googleAccessToken } from "../token-
|
|
1
|
+
import { n as googleAccessToken, r as googleHeaders } from "../token-CuyjDo6I.mjs";
|
|
2
2
|
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/onboarding/plan.ts
|
|
@@ -192,13 +192,10 @@ const inetSite = (domain) => ({
|
|
|
192
192
|
* record. It is deterministic per (user, domain), so re-fetching it and comparing
|
|
193
193
|
* against DNS is what makes the TXT step idempotent.
|
|
194
194
|
*/
|
|
195
|
-
async function getVerificationToken(
|
|
195
|
+
async function getVerificationToken(auth, domain, fetchImpl = fetch) {
|
|
196
196
|
const res = await fetchImpl(`${SITEVERIFICATION}/token`, {
|
|
197
197
|
method: "POST",
|
|
198
|
-
headers: {
|
|
199
|
-
authorization: `Bearer ${token}`,
|
|
200
|
-
"content-type": "application/json"
|
|
201
|
-
},
|
|
198
|
+
headers: googleHeaders(auth, { "content-type": "application/json" }),
|
|
202
199
|
body: JSON.stringify({
|
|
203
200
|
verificationMethod: "DNS_TXT",
|
|
204
201
|
site: inetSite(domain)
|
|
@@ -229,8 +226,8 @@ async function getVerificationToken(token, domain, fetchImpl = fetch) {
|
|
|
229
226
|
* contains resources the user already owns — so a hit *is* the verified state, and
|
|
230
227
|
* carries the id and owners the later steps need.
|
|
231
228
|
*/
|
|
232
|
-
async function getWebResource(
|
|
233
|
-
const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers:
|
|
229
|
+
async function getWebResource(auth, domain, fetchImpl = fetch) {
|
|
230
|
+
const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers: googleHeaders(auth) });
|
|
234
231
|
if (!res.ok) return {
|
|
235
232
|
ok: false,
|
|
236
233
|
status: res.status,
|
|
@@ -254,13 +251,10 @@ async function getWebResource(token, domain, fetchImpl = fetch) {
|
|
|
254
251
|
};
|
|
255
252
|
}
|
|
256
253
|
/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
|
|
257
|
-
async function verifyWebResource(
|
|
254
|
+
async function verifyWebResource(auth, domain, fetchImpl = fetch) {
|
|
258
255
|
const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {
|
|
259
256
|
method: "POST",
|
|
260
|
-
headers: {
|
|
261
|
-
authorization: `Bearer ${token}`,
|
|
262
|
-
"content-type": "application/json"
|
|
263
|
-
},
|
|
257
|
+
headers: googleHeaders(auth, { "content-type": "application/json" }),
|
|
264
258
|
body: JSON.stringify({
|
|
265
259
|
site: inetSite(domain),
|
|
266
260
|
verificationMethod: "DNS_TXT"
|
|
@@ -287,13 +281,10 @@ async function verifyWebResource(token, domain, fetchImpl = fetch) {
|
|
|
287
281
|
* current owners and the ones to add — never a bare list of new owners, which
|
|
288
282
|
* would drop everyone already there.
|
|
289
283
|
*/
|
|
290
|
-
async function updateOwners(
|
|
284
|
+
async function updateOwners(auth, id, domain, owners, fetchImpl = fetch) {
|
|
291
285
|
const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {
|
|
292
286
|
method: "PUT",
|
|
293
|
-
headers: {
|
|
294
|
-
authorization: `Bearer ${token}`,
|
|
295
|
-
"content-type": "application/json"
|
|
296
|
-
},
|
|
287
|
+
headers: googleHeaders(auth, { "content-type": "application/json" }),
|
|
297
288
|
body: JSON.stringify({
|
|
298
289
|
id,
|
|
299
290
|
site: inetSite(domain),
|
|
@@ -312,8 +303,8 @@ async function updateOwners(token, id, domain, owners, fetchImpl = fetch) {
|
|
|
312
303
|
}
|
|
313
304
|
const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
|
|
314
305
|
/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
|
|
315
|
-
async function getSite(
|
|
316
|
-
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers:
|
|
306
|
+
async function getSite(auth, siteUrl, fetchImpl = fetch) {
|
|
307
|
+
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: googleHeaders(auth) });
|
|
317
308
|
if (res.ok) return {
|
|
318
309
|
ok: true,
|
|
319
310
|
status: res.status,
|
|
@@ -337,10 +328,10 @@ async function getSite(token, siteUrl, fetchImpl = fetch) {
|
|
|
337
328
|
* A `403` here almost always means the domain is not yet verified for this user
|
|
338
329
|
* rather than a transient error — property creation is gated on ownership.
|
|
339
330
|
*/
|
|
340
|
-
async function addSite(
|
|
331
|
+
async function addSite(auth, siteUrl, fetchImpl = fetch) {
|
|
341
332
|
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {
|
|
342
333
|
method: "PUT",
|
|
343
|
-
headers:
|
|
334
|
+
headers: googleHeaders(auth)
|
|
344
335
|
});
|
|
345
336
|
if (res.ok) return {
|
|
346
337
|
ok: true,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n if (reminders.length) {\n out.push('', 'Reminders (manual — no API):', ...reminders);\n }\n return out.join('\\n');\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport { googleAccessToken, type GoogleOAuth } from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: { authorization: `Bearer ${token}` },\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n token: string,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n token: string,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n token: string,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n if (reminders.length) {\n out.push('', 'Reminders (manual — no API):', ...reminders);\n }\n return out.join('\\n');\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport {\n googleAccessToken,\n googleHeaders,\n type GoogleAuthLike,\n type GoogleOAuth,\n} from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: googleHeaders(auth),\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n auth: GoogleAuthLike,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n"],"mappings":"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"}
|
|
@@ -36,11 +36,38 @@ type GoogleCredential = {
|
|
|
36
36
|
* `now` is a parameter so the assertion is testable without mocking the clock.
|
|
37
37
|
*/
|
|
38
38
|
declare function googleAccessToken(credential: GoogleCredential, fetchImpl?: typeof fetch, now?: number): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* A bearer token, plus the project its usage is attributed to.
|
|
41
|
+
*
|
|
42
|
+
* `quotaProject` becomes the `x-goog-user-project` header, and it is **required**
|
|
43
|
+
* for a user credential — an ADC login or a `VITOPS_GOOGLE_*` refresh token
|
|
44
|
+
* authenticates through a shared OAuth client that carries no project, so Google
|
|
45
|
+
* refuses the call with a 403 naming the missing quota project rather than
|
|
46
|
+
* guessing one. A service account already belongs to a project, so it needs none.
|
|
47
|
+
*
|
|
48
|
+
* It is deliberately per-call rather than baked into the token: one agency
|
|
49
|
+
* identity administers many sites, each with its own project, so the identity is
|
|
50
|
+
* shared and the attribution is not.
|
|
51
|
+
*/
|
|
52
|
+
interface GoogleAuth {
|
|
53
|
+
token: string;
|
|
54
|
+
quotaProject?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A bare token still works everywhere an auth does.
|
|
58
|
+
*
|
|
59
|
+
* The token-only form is what every caller passed before per-site projects
|
|
60
|
+
* existed, and a service-account caller never needs more, so widening the
|
|
61
|
+
* parameter beats a breaking signature change across seven exported functions.
|
|
62
|
+
*/
|
|
63
|
+
type GoogleAuthLike = string | GoogleAuth;
|
|
64
|
+
/** Auth headers for a Google REST call, with the quota project when there is one. */
|
|
65
|
+
declare function googleHeaders(auth: GoogleAuthLike, extra?: Record<string, string>): Record<string, string>;
|
|
39
66
|
/** Scopes, named once so a caller never spells one out. */
|
|
40
67
|
declare const SCOPES: {
|
|
41
68
|
/** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */readonly webmasters: "https://www.googleapis.com/auth/webmasters"; /** Site Verification API. */
|
|
42
69
|
readonly siteVerification: "https://www.googleapis.com/auth/siteverification";
|
|
43
70
|
};
|
|
44
71
|
//#endregion
|
|
45
|
-
export {
|
|
46
|
-
//# sourceMappingURL=token
|
|
72
|
+
export { SCOPES as a, googleHeaders as c, GoogleOAuth as i, GoogleAuthLike as n, ServiceAccount as o, GoogleCredential as r, googleAccessToken as s, GoogleAuth as t };
|
|
73
|
+
//# sourceMappingURL=token-BlShEM6J.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token
|
|
1
|
+
{"version":3,"file":"token-BlShEM6J.d.mts","names":[],"sources":["../src/google/token.ts"],"mappings":";;UAkCiB,cAAA;EACf,YAAA;EACA,WAAW;AAAA;AAAA;AAAA,UAII,WAAA;EACf,QAAA;EACA,YAAA;EACA,YAAA;AAAA;;;;AAAY;AAWd;;;;KAAY,gBAAA;EACN,IAAA;EAAyB,OAAA,EAAS,cAAA;EAAgB,KAAA;AAAA;EAClD,IAAA;EAAe,KAAA,EAAO,WAAW;AAAA;AAAA;AA8CvC;;;;;;;;AA9CuC,iBA8CvB,iBAAA,CACd,UAAA,EAAY,gBAAA,EACZ,SAAA,UAAkB,KAAA,EAClB,GAAA,YACC,OAAA;;;;;;;AAAO;AA6BV;;;;AAEc;AAUd;UAZiB,UAAA;EACf,KAAA;EACA,YAAY;AAAA;AAad;;;;;;;AAAA,KAHY,cAAA,YAA0B,UAAU;;iBAGhC,aAAA,CACd,IAAA,EAAM,cAAA,EACN,KAAA,GAAQ,MAAA,mBACP,MAAA;;cAUU,MAAA;EAVV,6EAeO,UAAA,gDAfD;EAAA,SAeC,gBAAA;AAAA"}
|
|
@@ -83,6 +83,15 @@ function googleAccessToken(credential, fetchImpl = fetch, now = Date.now()) {
|
|
|
83
83
|
assertion: assertionFor(credential.account, credential.scope, now)
|
|
84
84
|
}), fetchImpl);
|
|
85
85
|
}
|
|
86
|
+
/** Auth headers for a Google REST call, with the quota project when there is one. */
|
|
87
|
+
function googleHeaders(auth, extra) {
|
|
88
|
+
const { token, quotaProject } = typeof auth === "string" ? { token: auth } : auth;
|
|
89
|
+
return {
|
|
90
|
+
authorization: `Bearer ${token}`,
|
|
91
|
+
...quotaProject ? { "x-goog-user-project": quotaProject } : {},
|
|
92
|
+
...extra
|
|
93
|
+
};
|
|
94
|
+
}
|
|
86
95
|
/** Scopes, named once so a caller never spells one out. */
|
|
87
96
|
const SCOPES = {
|
|
88
97
|
/** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */
|
|
@@ -92,5 +101,5 @@ const SCOPES = {
|
|
|
92
101
|
};
|
|
93
102
|
|
|
94
103
|
//#endregion
|
|
95
|
-
export { googleAccessToken as n, SCOPES as t };
|
|
96
|
-
//# sourceMappingURL=token-
|
|
104
|
+
export { googleAccessToken as n, googleHeaders as r, SCOPES as t };
|
|
105
|
+
//# sourceMappingURL=token-CuyjDo6I.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token-
|
|
1
|
+
{"version":3,"file":"token-CuyjDo6I.mjs","names":[],"sources":["../src/google/token.ts"],"sourcesContent":["/**\n * Google OAuth2 token exchange — one request, two grants.\n *\n * Both of this package's Google modules need a bearer token, and both used to\n * mint one themselves. The two copies looked different because the *credentials*\n * differ, but everything after building the form body was identical: same\n * endpoint, same content-type, same \"surface the response body as the error\"\n * rule, same `access_token` extraction. So the seam is precise —\n * **credential → form params differs, params → token does not.**\n *\n * The two grants are kept because they are not accidental duplication; they track\n * how each command is actually run:\n *\n * - **service account** (RS256 JWT bearer) for `search notify`, which runs on\n * every deploy in CI. It never expires. A refresh token can be revoked, and\n * for an OAuth client still in *Testing* publishing status Google expires it\n * after 7 days — a bad property for a deploy step.\n * - **user OAuth** (refresh-token grant) for `search setup`, which is a\n * one-time human operation. Site verification makes the calling identity an\n * *owner* of the property, and that should be a person, not a project robot.\n *\n * `googleapis` is deliberately not a dependency — this is one REST endpoint in a\n * CLI that installs into every consumer project. See AGENTS.md.\n *\n * Internal: not a published subpath. Both `./indexing` and `./onboarding` import\n * it, so it lands in a shared chunk, which is free for a Node library. (The\n * \"don't create a shared chunk\" rule in AGENTS.md is about browser bundles and\n * the consent gate.)\n */\nimport { createSign } from 'node:crypto';\n\nconst TOKEN_URL = 'https://oauth2.googleapis.com/token';\n\n/** The fields we need from a service-account JSON key. */\nexport interface ServiceAccount {\n client_email: string;\n private_key: string;\n}\n\n/** A user OAuth credential (refresh-token flow — never a service account). */\nexport interface GoogleOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Which identity to act as.\n *\n * `scope` rides on the service-account variant because that grant carries its\n * scope in the signed assertion. A user OAuth token's scopes are fixed when the\n * refresh token is minted, so there is nothing to pass at exchange time — the\n * asymmetry is the protocol's, not ours.\n */\nexport type GoogleCredential =\n | { kind: 'service-account'; account: ServiceAccount; scope: string }\n | { kind: 'oauth'; oauth: GoogleOAuth };\n\nconst b64url = (input: Buffer | string): string =>\n Buffer.from(input).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n\n/** Sign the RS256 JWT assertion a service-account grant carries. */\nfunction assertionFor(account: ServiceAccount, scope: string, now: number): string {\n const iat = Math.floor(now / 1000);\n const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = b64url(\n JSON.stringify({ iss: account.client_email, scope, aud: TOKEN_URL, iat, exp: iat + 3600 }),\n );\n const signer = createSign('RSA-SHA256');\n signer.update(`${header}.${claims}`);\n // `\\n` survives most secret stores as a literal; normalise so a pasted key works.\n const pem = account.private_key.replace(/\\\\n/g, '\\n');\n return `${header}.${claims}.${b64url(signer.sign(pem))}`;\n}\n\n/** The half that never differs: POST the grant, surface the cause, return the token. */\nasync function requestToken(body: URLSearchParams, fetchImpl: typeof fetch): Promise<string> {\n const res = await fetchImpl(TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n if (!res.ok) {\n // The body names the cause — clock skew, wrong audience or a revoked key for a\n // service account; `invalid_grant` / `invalid_client` for OAuth — and carries\n // no secret of ours: the assertion travels in the request, not the response.\n throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);\n }\n const { access_token } = (await res.json()) as { access_token?: string };\n if (!access_token) throw new Error('token exchange returned no access_token');\n return access_token;\n}\n\n/**\n * Exchange either credential for a short-lived access token.\n *\n * Throws rather than returning a result: nothing downstream can run without a\n * token, so there is no per-item decision for a caller to make. (The API calls\n * themselves do return structured results — those fail per domain.)\n *\n * `now` is a parameter so the assertion is testable without mocking the clock.\n */\nexport function googleAccessToken(\n credential: GoogleCredential,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const body =\n credential.kind === 'oauth'\n ? new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: credential.oauth.clientId,\n client_secret: credential.oauth.clientSecret,\n refresh_token: credential.oauth.refreshToken,\n })\n : new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: assertionFor(credential.account, credential.scope, now),\n });\n return requestToken(body, fetchImpl);\n}\n\n/**\n * A bearer token, plus the project its usage is attributed to.\n *\n * `quotaProject` becomes the `x-goog-user-project` header, and it is **required**\n * for a user credential — an ADC login or a `VITOPS_GOOGLE_*` refresh token\n * authenticates through a shared OAuth client that carries no project, so Google\n * refuses the call with a 403 naming the missing quota project rather than\n * guessing one. A service account already belongs to a project, so it needs none.\n *\n * It is deliberately per-call rather than baked into the token: one agency\n * identity administers many sites, each with its own project, so the identity is\n * shared and the attribution is not.\n */\nexport interface GoogleAuth {\n token: string;\n quotaProject?: string;\n}\n\n/**\n * A bare token still works everywhere an auth does.\n *\n * The token-only form is what every caller passed before per-site projects\n * existed, and a service-account caller never needs more, so widening the\n * parameter beats a breaking signature change across seven exported functions.\n */\nexport type GoogleAuthLike = string | GoogleAuth;\n\n/** Auth headers for a Google REST call, with the quota project when there is one. */\nexport function googleHeaders(\n auth: GoogleAuthLike,\n extra?: Record<string, string>,\n): Record<string, string> {\n const { token, quotaProject } = typeof auth === 'string' ? { token: auth } : auth;\n return {\n authorization: `Bearer ${token}`,\n ...(quotaProject ? { 'x-goog-user-project': quotaProject } : {}),\n ...extra,\n };\n}\n\n/** Scopes, named once so a caller never spells one out. */\nexport const SCOPES = {\n /** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */\n webmasters: 'https://www.googleapis.com/auth/webmasters',\n /** Site Verification API. */\n siteVerification: 'https://www.googleapis.com/auth/siteverification',\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,MAAM,YAAY;AA2BlB,MAAM,UAAU,UACd,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;;AAGjG,SAAS,aAAa,SAAyB,OAAe,KAAqB;CACjF,MAAM,MAAM,KAAK,MAAM,MAAM,GAAI;CACjC,MAAM,SAAS,OAAO,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK;CAAM,CAAC,CAAC;CAClE,MAAM,SAAS,OACb,KAAK,UAAU;EAAE,KAAK,QAAQ;EAAc;EAAO,KAAK;EAAW;EAAK,KAAK,MAAM;CAAK,CAAC,CAC3F;CACA,MAAM,SAAS,WAAW,YAAY;CACtC,OAAO,OAAO,GAAG,OAAO,GAAG,QAAQ;CAEnC,MAAM,MAAM,QAAQ,YAAY,QAAQ,QAAQ,IAAI;CACpD,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,OAAO,KAAK,GAAG,CAAC;AACvD;;AAGA,eAAe,aAAa,MAAuB,WAA0C;CAC3F,MAAM,MAAM,MAAM,UAAU,WAAW;EACrC,QAAQ;EACR,SAAS,EAAE,gBAAgB,oCAAoC;EAC/D,MAAM,KAAK,SAAS;CACtB,CAAC;CACD,IAAI,CAAC,IAAI,IAIP,MAAM,IAAI,MAAM,0BAA0B,IAAI,OAAO,KAAK,MAAM,IAAI,KAAK,GAAG;CAE9E,MAAM,EAAE,iBAAkB,MAAM,IAAI,KAAK;CACzC,IAAI,CAAC,cAAc,MAAM,IAAI,MAAM,yCAAyC;CAC5E,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,kBACd,YACA,YAA0B,OAC1B,MAAc,KAAK,IAAI,GACN;CAajB,OAAO,aAXL,WAAW,SAAS,UAChB,IAAI,gBAAgB;EAClB,YAAY;EACZ,WAAW,WAAW,MAAM;EAC5B,eAAe,WAAW,MAAM;EAChC,eAAe,WAAW,MAAM;CAClC,CAAC,IACD,IAAI,gBAAgB;EAClB,YAAY;EACZ,WAAW,aAAa,WAAW,SAAS,WAAW,OAAO,GAAG;CACnE,CAAC,GACmB,SAAS;AACrC;;AA8BA,SAAgB,cACd,MACA,OACwB;CACxB,MAAM,EAAE,OAAO,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI;CAC7E,OAAO;EACL,eAAe,UAAU;EACzB,GAAI,eAAe,EAAE,uBAAuB,aAAa,IAAI,CAAC;EAC9D,GAAG;CACL;AACF;;AAGA,MAAa,SAAS;;CAEpB,YAAY;;CAEZ,kBAAkB;AACpB"}
|