@getvitops/utils 3.0.0 → 4.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.
@@ -0,0 +1,448 @@
1
+ import { n as googleAccessToken } from "../token-BV7tPHh2.mjs";
2
+
3
+ //#region src/onboarding/plan.ts
4
+ /** The `sc-domain:` property identifier for a bare domain. */
5
+ const siteUrlFor = (domain) => `sc-domain:${domain}`;
6
+ /**
7
+ * Backoff ladder for the verification retry, in milliseconds.
8
+ *
9
+ * DNS_TXT verification fails until the record has propagated, which is a matter of
10
+ * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided
11
+ * here and asserted in the tests, while the actual sleeping is the CLI's job.
12
+ * Capped at `maxAttempts`; a domain still unverified after the last attempt is
13
+ * reported PENDING, never failed.
14
+ */
15
+ function backoffSchedule(maxAttempts = 5) {
16
+ const out = [];
17
+ for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1e3);
18
+ return out;
19
+ }
20
+ /** Decide the four steps for one domain from its desired config and live state. */
21
+ function planDomain(setup, state) {
22
+ const reminders = [];
23
+ if (setup.fullUserGroup) reminders.push(`add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`);
24
+ const txt = state.txtPresent ? {
25
+ action: "skip",
26
+ detail: "apex TXT already present"
27
+ } : {
28
+ action: "create",
29
+ detail: "create apex TXT verification record"
30
+ };
31
+ const verify = state.verified ? {
32
+ action: "skip",
33
+ detail: "ownership already verified"
34
+ } : {
35
+ action: "create",
36
+ detail: "verify ownership (DNS_TXT)"
37
+ };
38
+ const property = state.propertyExists ? {
39
+ action: "skip",
40
+ detail: "property already exists"
41
+ } : {
42
+ action: "create",
43
+ detail: `add property ${siteUrlFor(setup.domain)}`
44
+ };
45
+ const desired = setup.delegatedOwners ?? [];
46
+ const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));
47
+ const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));
48
+ const owners = desired.length === 0 ? {
49
+ action: "skip",
50
+ detail: "no delegated owners"
51
+ } : ownersToAdd.length === 0 ? {
52
+ action: "skip",
53
+ detail: "all delegated owners already present"
54
+ } : {
55
+ action: "update",
56
+ detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(", ")}`
57
+ };
58
+ return {
59
+ domain: setup.domain,
60
+ siteUrl: siteUrlFor(setup.domain),
61
+ txt,
62
+ verify,
63
+ property,
64
+ owners,
65
+ ownersToAdd,
66
+ reminders
67
+ };
68
+ }
69
+ /**
70
+ * The owner set to PUT: everyone already there, plus everyone being added.
71
+ *
72
+ * `updateOwners` REPLACES the owner list, so this is the one computation standing
73
+ * between a delegation and locking yourself out — pass a bare `ownersToAdd` and
74
+ * the account that just verified the domain is removed from it. It belongs here
75
+ * rather than at the call site for exactly that reason: it is a decision, it is
76
+ * the dangerous one, and the executor is not allowed to make it.
77
+ *
78
+ * `current` comes FIRST so the API's own casing survives dedupe; matching is
79
+ * case-insensitive, mirroring `planDomain`'s `present` check, because Google
80
+ * echoes back addresses in whatever case the account uses and a case-sensitive
81
+ * `Set` would re-add an owner who is already there.
82
+ */
83
+ function ownerUnion(current, toAdd) {
84
+ const out = [];
85
+ const seen = /* @__PURE__ */ new Set();
86
+ for (const o of [...current, ...toAdd]) {
87
+ const key = o.toLowerCase();
88
+ if (seen.has(key)) continue;
89
+ seen.add(key);
90
+ out.push(o);
91
+ }
92
+ return out;
93
+ }
94
+ /** Plan every domain. */
95
+ function plan(config, states) {
96
+ const notes = [];
97
+ return {
98
+ domains: config.domains.map((setup) => {
99
+ const state = states.get(setup.domain);
100
+ if (!state) notes.push(`${setup.domain}: no observed state; treating everything as pending`);
101
+ return planDomain(setup, state ?? {
102
+ txtPresent: false,
103
+ verified: false,
104
+ currentOwners: [],
105
+ propertyExists: false
106
+ });
107
+ }),
108
+ notes
109
+ };
110
+ }
111
+ /** Any step on any domain is a mutation — i.e. the live state has drifted. */
112
+ function hasDrift(p) {
113
+ return p.domains.some((d) => d.txt.action !== "skip" || d.verify.action !== "skip" || d.property.action !== "skip" || d.owners.action !== "skip");
114
+ }
115
+ /** Render a plan as the block `--dry` and `--check` print. */
116
+ function formatPlan(p) {
117
+ const lines = [];
118
+ const glyph = (s) => s.action === "skip" ? "·" : "+";
119
+ for (const d of p.domains) {
120
+ lines.push(d.domain);
121
+ lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);
122
+ lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);
123
+ lines.push(` ${glyph(d.property)} property ${d.property.detail}`);
124
+ lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);
125
+ for (const r of d.reminders) lines.push(` ! ${r}`);
126
+ }
127
+ for (const n of p.notes) lines.push(`! ${n}`);
128
+ return lines.join("\n");
129
+ }
130
+ const pad = (s, n) => s.padEnd(n);
131
+ /** Render the end-of-run summary: one row per domain, plus any reminders. */
132
+ function formatSummary(results) {
133
+ const headers = [
134
+ "DOMAIN",
135
+ "TXT",
136
+ "VERIFIED",
137
+ "PROPERTY"
138
+ ];
139
+ const rows = results.map((r) => [
140
+ r.domain,
141
+ r.txt,
142
+ r.verified,
143
+ r.property
144
+ ]);
145
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
146
+ const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
147
+ const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
148
+ for (const row of rows) out.push(line(row));
149
+ const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));
150
+ if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
151
+ return out.join("\n");
152
+ }
153
+
154
+ //#endregion
155
+ //#region src/onboarding/cloudflare.ts
156
+ /**
157
+ * Cloudflare DNS — the one thing this command writes.
158
+ *
159
+ * Scoped to exactly what onboarding needs and nothing more: find a zone by name,
160
+ * read the apex TXT records, and *create* the verification TXT when it is missing.
161
+ * There is deliberately no update and no delete — the command's contract is that it
162
+ * never removes a DNS record, so the destructive verbs are simply absent rather
163
+ * than guarded.
164
+ *
165
+ * The token is a `Zone:DNS:Edit` credential from the environment
166
+ * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which
167
+ * Cloudflare's standard "Edit zone DNS" token template already bundles. As with the
168
+ * indexing executors, `fetchImpl` is injected so the request shape is testable
169
+ * without the network, and every call returns a structured result rather than
170
+ * throwing — the caller decides what a failure means per domain.
171
+ */
172
+ const API = "https://api.cloudflare.com/client/v4";
173
+ const cfError = (status, body) => {
174
+ const first = body.errors?.[0];
175
+ return first ? `${first.code} ${first.message}` : `HTTP ${status}`;
176
+ };
177
+ /** Find the zone id for a domain (`GET /zones?name=`). */
178
+ async function findZoneId(token, domain, fetchImpl = fetch) {
179
+ const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, { headers: { authorization: `Bearer ${token}` } });
180
+ const body = await res.json();
181
+ if (!res.ok || !body.success) return {
182
+ ok: false,
183
+ status: res.status,
184
+ message: cfError(res.status, body)
185
+ };
186
+ const zone = body.result?.[0];
187
+ if (!zone) return {
188
+ ok: false,
189
+ status: res.status,
190
+ message: `no Cloudflare zone named "${domain}" (is it in this account, and does the token cover it?)`
191
+ };
192
+ return {
193
+ ok: true,
194
+ status: res.status,
195
+ zoneId: zone.id
196
+ };
197
+ }
198
+ /** List the apex TXT record contents for a zone. */
199
+ async function listApexTxt(token, zoneId, domain, fetchImpl = fetch) {
200
+ const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`, { headers: { authorization: `Bearer ${token}` } });
201
+ const body = await res.json();
202
+ if (!res.ok || !body.success) return {
203
+ ok: false,
204
+ status: res.status,
205
+ contents: [],
206
+ message: cfError(res.status, body)
207
+ };
208
+ const contents = (body.result ?? []).map((r) => r.content.replace(/^"|"$/g, ""));
209
+ return {
210
+ ok: true,
211
+ status: res.status,
212
+ contents
213
+ };
214
+ }
215
+ /**
216
+ * Create an apex TXT record. **Create only** — never called when one already
217
+ * exists (the planner decides that), and there is no sibling that edits or removes.
218
+ */
219
+ async function createApexTxt(token, zoneId, domain, content, fetchImpl = fetch) {
220
+ const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {
221
+ method: "POST",
222
+ headers: {
223
+ authorization: `Bearer ${token}`,
224
+ "content-type": "application/json"
225
+ },
226
+ body: JSON.stringify({
227
+ type: "TXT",
228
+ name: domain,
229
+ content
230
+ })
231
+ });
232
+ const body = await res.json();
233
+ if (!res.ok || !body.success) return {
234
+ ok: false,
235
+ status: res.status,
236
+ message: cfError(res.status, body)
237
+ };
238
+ return {
239
+ ok: true,
240
+ status: res.status
241
+ };
242
+ }
243
+
244
+ //#endregion
245
+ //#region src/onboarding/google.ts
246
+ /**
247
+ * Google — the two APIs onboarding drives, and the user-OAuth token behind both.
248
+ *
249
+ * Auth here is a **user OAuth refresh token**, not a service account: verifying a
250
+ * site and adding a Search Console property act *as a person* who can own them, not
251
+ * as a project robot. That makes the token exchange simpler than the indexing
252
+ * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs
253
+ * broader scope, granted when the refresh token is minted:
254
+ * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)
255
+ * - `https://www.googleapis.com/auth/webmasters` (Search Console API)
256
+ *
257
+ * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`
258
+ * — the grants differ, everything after building the form body did not. It throws
259
+ * (nothing downstream can run without a token) while every API call below returns
260
+ * a structured result and lets the caller decide per domain. `googleapis` is
261
+ * deliberately not a dependency — these are a handful of REST endpoints in a CLI
262
+ * that installs into every consumer.
263
+ */
264
+ const SITEVERIFICATION = "https://www.googleapis.com/siteVerification/v1";
265
+ const WEBMASTERS = "https://www.googleapis.com/webmasters/v3";
266
+ /** Exchange a user OAuth refresh token for a short-lived access token. */
267
+ function refreshTokenGrant(oauth, fetchImpl = fetch) {
268
+ return googleAccessToken({
269
+ kind: "oauth",
270
+ oauth
271
+ }, fetchImpl);
272
+ }
273
+ const inetSite = (domain) => ({
274
+ type: "INET_DOMAIN",
275
+ identifier: domain
276
+ });
277
+ /**
278
+ * Ask the Site Verification API for the DNS_TXT token for a domain.
279
+ *
280
+ * Returns the full `google-site-verification=…` value that goes in the apex TXT
281
+ * record. It is deterministic per (user, domain), so re-fetching it and comparing
282
+ * against DNS is what makes the TXT step idempotent.
283
+ */
284
+ async function getVerificationToken(token, domain, fetchImpl = fetch) {
285
+ const res = await fetchImpl(`${SITEVERIFICATION}/token`, {
286
+ method: "POST",
287
+ headers: {
288
+ authorization: `Bearer ${token}`,
289
+ "content-type": "application/json"
290
+ },
291
+ body: JSON.stringify({
292
+ verificationMethod: "DNS_TXT",
293
+ site: inetSite(domain)
294
+ })
295
+ });
296
+ if (!res.ok) return {
297
+ ok: false,
298
+ status: res.status,
299
+ message: await res.text()
300
+ };
301
+ const body = await res.json();
302
+ if (!body.token) return {
303
+ ok: false,
304
+ status: res.status,
305
+ message: "no token in response"
306
+ };
307
+ return {
308
+ ok: true,
309
+ status: res.status,
310
+ token: body.token
311
+ };
312
+ }
313
+ /**
314
+ * Look up the verified web resource for a domain, if any.
315
+ *
316
+ * Uses `webResource.list` rather than a `get` by id, because the id format for an
317
+ * INET_DOMAIN resource is awkward to construct by hand and the list only ever
318
+ * contains resources the user already owns — so a hit *is* the verified state, and
319
+ * carries the id and owners the later steps need.
320
+ */
321
+ async function getWebResource(token, domain, fetchImpl = fetch) {
322
+ const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers: { authorization: `Bearer ${token}` } });
323
+ if (!res.ok) return {
324
+ ok: false,
325
+ status: res.status,
326
+ exists: false,
327
+ owners: [],
328
+ message: await res.text()
329
+ };
330
+ const match = (await res.json()).items?.find((it) => it.site?.type === "INET_DOMAIN" && it.site?.identifier === domain);
331
+ if (!match) return {
332
+ ok: true,
333
+ status: res.status,
334
+ exists: false,
335
+ owners: []
336
+ };
337
+ return {
338
+ ok: true,
339
+ status: res.status,
340
+ exists: true,
341
+ ...match.id ? { id: match.id } : {},
342
+ owners: match.owners ?? []
343
+ };
344
+ }
345
+ /** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
346
+ async function verifyWebResource(token, domain, fetchImpl = fetch) {
347
+ const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {
348
+ method: "POST",
349
+ headers: {
350
+ authorization: `Bearer ${token}`,
351
+ "content-type": "application/json"
352
+ },
353
+ body: JSON.stringify({
354
+ site: inetSite(domain),
355
+ verificationMethod: "DNS_TXT"
356
+ })
357
+ });
358
+ if (!res.ok) return {
359
+ ok: false,
360
+ status: res.status,
361
+ owners: [],
362
+ message: await res.text()
363
+ };
364
+ const body = await res.json();
365
+ return {
366
+ ok: true,
367
+ status: res.status,
368
+ ...body.id ? { id: body.id } : {},
369
+ owners: body.owners ?? []
370
+ };
371
+ }
372
+ /**
373
+ * Set the owners of a verified web resource to a given list.
374
+ *
375
+ * The API replaces the owner set, so the caller passes the **union** of the
376
+ * current owners and the ones to add — never a bare list of new owners, which
377
+ * would drop everyone already there.
378
+ */
379
+ async function updateOwners(token, id, domain, owners, fetchImpl = fetch) {
380
+ const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {
381
+ method: "PUT",
382
+ headers: {
383
+ authorization: `Bearer ${token}`,
384
+ "content-type": "application/json"
385
+ },
386
+ body: JSON.stringify({
387
+ id,
388
+ site: inetSite(domain),
389
+ owners
390
+ })
391
+ });
392
+ if (!res.ok) return {
393
+ ok: false,
394
+ status: res.status,
395
+ message: await res.text()
396
+ };
397
+ return {
398
+ ok: true,
399
+ status: res.status
400
+ };
401
+ }
402
+ const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
403
+ /** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
404
+ async function getSite(token, siteUrl, fetchImpl = fetch) {
405
+ const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: { authorization: `Bearer ${token}` } });
406
+ if (res.ok) return {
407
+ ok: true,
408
+ status: res.status,
409
+ exists: true
410
+ };
411
+ if (res.status === 404) return {
412
+ ok: true,
413
+ status: res.status,
414
+ exists: false
415
+ };
416
+ return {
417
+ ok: false,
418
+ status: res.status,
419
+ exists: false,
420
+ message: await res.text()
421
+ };
422
+ }
423
+ /**
424
+ * Add a Search Console property (`PUT sites/{siteUrl}`).
425
+ *
426
+ * A `403` here almost always means the domain is not yet verified for this user
427
+ * rather than a transient error — property creation is gated on ownership.
428
+ */
429
+ async function addSite(token, siteUrl, fetchImpl = fetch) {
430
+ const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {
431
+ method: "PUT",
432
+ headers: { authorization: `Bearer ${token}` }
433
+ });
434
+ if (res.ok) return {
435
+ ok: true,
436
+ status: res.status
437
+ };
438
+ const detail = res.status === 403 ? `403 — "${siteUrl}" is not verified for this account yet, or the OAuth token lacks the webmasters scope` : `${res.status}: ${await res.text()}`;
439
+ return {
440
+ ok: false,
441
+ status: res.status,
442
+ message: detail
443
+ };
444
+ }
445
+
446
+ //#endregion
447
+ export { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan, formatSummary, getSite, getVerificationToken, getWebResource, hasDrift, listApexTxt, ownerUnion, plan, planDomain, refreshTokenGrant, siteUrlFor, updateOwners, verifyWebResource };
448
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/cloudflare.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 * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (!zone)\n return {\n ok: false,\n status: res.status,\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token cover it?)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\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":";;;;AAkDA,MAAa,cAAc,WAA2B,aAAa;;;;;;;;;;AAWnE,SAAgB,gBAAgB,cAAc,GAAa;CACzD,MAAM,MAAgB,CAAC;CACvB,KAAK,IAAI,IAAI,GAAG,KAAK,aAAa,KAAK,IAAI,KAAK,KAAK,IAAI,GAAI;CAC7D,OAAO;AACT;;AAGA,SAAgB,WAAW,OAAoB,OAAgC;CAC7E,MAAM,YAAsB,CAAC;CAC7B,IAAI,MAAM,eACR,UAAU,KACR,OAAO,MAAM,cAAc,4EAC7B;CAEF,MAAM,MAAgB,MAAM,aACxB;EAAE,QAAQ;EAAQ,QAAQ;CAA2B,IACrD;EAAE,QAAQ;EAAU,QAAQ;CAAsC;CAEtE,MAAM,SAAmB,MAAM,WAC3B;EAAE,QAAQ;EAAQ,QAAQ;CAA6B,IACvD;EAAE,QAAQ;EAAU,QAAQ;CAA6B;CAE7D,MAAM,WAAqB,MAAM,iBAC7B;EAAE,QAAQ;EAAQ,QAAQ;CAA0B,IACpD;EAAE,QAAQ;EAAU,QAAQ,gBAAgB,WAAW,MAAM,MAAM;CAAI;CAI3E,MAAM,UAAU,MAAM,mBAAmB,CAAC;CAC1C,MAAM,UAAU,IAAI,IAAI,MAAM,cAAc,KAAK,MAAM,EAAE,YAAY,CAAC,CAAC;CACvE,MAAM,cAAc,QAAQ,QAAQ,MAAM,CAAC,QAAQ,IAAI,EAAE,YAAY,CAAC,CAAC;CACvE,MAAM,SACJ,QAAQ,WAAW,IACf;EAAE,QAAQ;EAAQ,QAAQ;CAAsB,IAChD,YAAY,WAAW,IACrB;EAAE,QAAQ;EAAQ,QAAQ;CAAuC,IACjE;EACE,QAAQ;EACR,QAAQ,OAAO,YAAY,OAAO,aAAa,YAAY,KAAK,IAAI;CACtE;CAER,OAAO;EACL,QAAQ,MAAM;EACd,SAAS,WAAW,MAAM,MAAM;EAChC;EACA;EACA;EACA;EACA;EACA;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,WAAW,SAAmB,OAA2B;CACvE,MAAM,MAAgB,CAAC;CACvB,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,MAAM,KAAK,CAAC,GAAG,SAAS,GAAG,KAAK,GAAG;EACtC,MAAM,MAAM,EAAE,YAAY;EAC1B,IAAI,KAAK,IAAI,GAAG,GAAG;EACnB,KAAK,IAAI,GAAG;EACZ,IAAI,KAAK,CAAC;CACZ;CACA,OAAO;AACT;;AAGA,SAAgB,KAAK,QAA0B,QAAkD;CAC/F,MAAM,QAAkB,CAAC;CAYzB,OAAO;EAAE,SAXO,OAAO,QAAQ,KAAK,UAAU;GAC5C,MAAM,QAAQ,OAAO,IAAI,MAAM,MAAM;GACrC,IAAI,CAAC,OAGH,MAAM,KAAK,GAAG,MAAM,OAAO,oDAAoD;GACjF,OAAO,WACL,OACA,SAAS;IAAE,YAAY;IAAO,UAAU;IAAO,eAAe,CAAC;IAAG,gBAAgB;GAAM,CAC1F;EACF,CACe;EAAG;CAAM;AAC1B;;AAGA,SAAgB,SAAS,GAA4B;CACnD,OAAO,EAAE,QAAQ,MACd,MACC,EAAE,IAAI,WAAW,UACjB,EAAE,OAAO,WAAW,UACpB,EAAE,SAAS,WAAW,UACtB,EAAE,OAAO,WAAW,MACxB;AACF;;AAGA,SAAgB,WAAW,GAA2B;CACpD,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,MAAiB,EAAE,WAAW,SAAS,MAAM;CAC5D,KAAK,MAAM,KAAK,EAAE,SAAS;EACzB,MAAM,KAAK,EAAE,MAAM;EACnB,MAAM,KAAK,KAAK,MAAM,EAAE,GAAG,EAAE,aAAa,EAAE,IAAI,QAAQ;EACxD,MAAM,KAAK,KAAK,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,QAAQ;EAC9D,MAAM,KAAK,KAAK,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,SAAS,QAAQ;EAClE,MAAM,KAAK,KAAK,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,QAAQ;EAC9D,KAAK,MAAM,KAAK,EAAE,WAAW,MAAM,KAAK,OAAO,GAAG;CACpD;CACA,KAAK,MAAM,KAAK,EAAE,OAAO,MAAM,KAAK,KAAK,GAAG;CAC5C,OAAO,MAAM,KAAK,IAAI;AACxB;AAWA,MAAM,OAAO,GAAW,MAAc,EAAE,OAAO,CAAC;;AAGhD,SAAgB,cAAc,SAAiC;CAC7D,MAAM,UAAU;EAAC;EAAU;EAAO;EAAY;CAAU;CACxD,MAAM,OAAO,QAAQ,KAAK,MAAM;EAAC,EAAE;EAAQ,EAAE;EAAK,EAAE;EAAU,EAAE;CAAQ,CAAC;CACzE,MAAM,SAAS,QAAQ,KAAK,GAAG,MAC7B,KAAK,IAAI,EAAE,QAAQ,GAAG,KAAK,KAAK,QAAQ,IAAI,EAAE,EAAE,UAAU,CAAC,CAAC,CAC9D;CACA,MAAM,QAAQ,UACZ,MACG,KAAK,GAAG,MAAM,IAAI,GAAG,OAAO,MAAM,CAAC,CAAC,CAAC,CACrC,KAAK,IAAI,CAAC,CACV,QAAQ;CAEb,MAAM,MAAM,CAAC,KAAK,OAAO,GAAG,OAAO,KAAK,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;CACvE,KAAK,MAAM,OAAO,MAAM,IAAI,KAAK,KAAK,GAAG,CAAC;CAE1C,MAAM,YAAY,QAAQ,SAAS,MAAM,EAAE,UAAU,KAAK,MAAM,OAAO,EAAE,OAAO,IAAI,GAAG,CAAC;CACxF,IAAI,UAAU,QACZ,IAAI,KAAK,IAAI,gCAAgC,GAAG,SAAS;CAE3D,OAAO,IAAI,KAAK,IAAI;AACtB;;;;;;;;;;;;;;;;;;;;ACxMA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EACZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC;;;;;;;;;;;;;;;;;;;;;;ACtEA,MAAM,mBAAmB;AACzB,MAAM,aAAa;;AAGnB,SAAgB,kBACd,OACA,YAA0B,OACT;CACjB,OAAO,kBAAkB;EAAE,MAAM;EAAS;CAAM,GAAG,SAAS;AAC9D;AAEA,MAAM,YAAY,YAAoB;CAAE,MAAM;CAAe,YAAY;AAAO;;;;;;;;AAShF,eAAsB,qBACpB,OACA,QACA,YAA0B,OACkD;CAC5E,MAAM,MAAM,MAAM,UAAU,GAAG,iBAAiB,SAAS;EACvD,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,oBAAoB;GAAW,MAAM,SAAS,MAAM;EAAE,CAAC;CAChF,CAAC;CACD,IAAI,CAAC,IAAI,IAAI,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,MAAM,IAAI,KAAK;CAAE;CAC/E,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,KAAK,OAAO,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS;CAAuB;CACzF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,OAAO,KAAK;CAAM;AAC3D;;;;;;;;;AAUA,eAAsB,eACpB,OACA,QACA,YAA0B,OAQzB;CACD,MAAM,MAAM,MAAM,UAAU,GAAG,iBAAiB,eAAe,EAC7D,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,IAAI,CAAC,IAAI,IACP,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,QAAQ;EAAO,QAAQ,CAAC;EAAG,SAAS,MAAM,IAAI,KAAK;CAAE;CAI/F,MAAM,SAAQ,MAHM,IAAI,KAAK,EAGX,CAAC,OAAO,MACvB,OAAO,GAAG,MAAM,SAAS,iBAAiB,GAAG,MAAM,eAAe,MACrE;CACA,IAAI,CAAC,OAAO,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ;EAAO,QAAQ,CAAC;CAAE;CAC7E,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EACZ,QAAQ;EACR,GAAI,MAAM,KAAK,EAAE,IAAI,MAAM,GAAG,IAAI,CAAC;EACnC,QAAQ,MAAM,UAAU,CAAC;CAC3B;AACF;;AAGA,eAAsB,kBACpB,OACA,QACA,YAA0B,OACiE;CAC3F,MAAM,MAAM,MAAM,UAAU,GAAG,iBAAiB,0CAA0C;EACxF,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM,SAAS,MAAM;GAAG,oBAAoB;EAAU,CAAC;CAChF,CAAC;CACD,IAAI,CAAC,IAAI,IAAI,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,QAAQ,CAAC;EAAG,SAAS,MAAM,IAAI,KAAK;CAAE;CAC3F,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EACZ,GAAI,KAAK,KAAK,EAAE,IAAI,KAAK,GAAG,IAAI,CAAC;EACjC,QAAQ,KAAK,UAAU,CAAC;CAC1B;AACF;;;;;;;;AASA,eAAsB,aACpB,OACA,IACA,QACA,QACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,iBAAiB,eAAe,mBAAmB,EAAE,KAAK;EACvF,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE;GAAI,MAAM,SAAS,MAAM;GAAG;EAAO,CAAC;CAC7D,CAAC;CACD,IAAI,CAAC,IAAI,IAAI,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,MAAM,IAAI,KAAK;CAAE;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC;AAEA,MAAM,cAAc,YAAoB,mBAAmB,OAAO;;AAGlE,eAAsB,QACpB,OACA,SACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,WAAW,SAAS,WAAW,OAAO,KAAK,EACxE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,IAAI,IAAI,IAAI,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ;CAAK;CAChE,IAAI,IAAI,WAAW,KAAK,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ;CAAM;CAC7E,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,QAAQ;EAAO,SAAS,MAAM,IAAI,KAAK;CAAE;AACnF;;;;;;;AAQA,eAAsB,QACpB,OACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,WAAW,SAAS,WAAW,OAAO,KAAK;EACxE,QAAQ;EACR,SAAS,EAAE,eAAe,UAAU,QAAQ;CAC9C,CAAC;CACD,IAAI,IAAI,IAAI,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;CAClD,MAAM,SACJ,IAAI,WAAW,MACX,UAAU,QAAQ,yFAClB,GAAG,IAAI,OAAO,IAAI,MAAM,IAAI,KAAK;CACvC,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS;CAAO;AAC1D"}
@@ -0,0 +1,88 @@
1
+ //#region src/tracking/params.ts
2
+ /**
3
+ * The ad-platform vocabulary — the one place that says which URL parameters mean
4
+ * "this visit came from an ad", and which platform each belongs to.
5
+ *
6
+ * Single source of truth on purpose: the capture script reads it to know what to
7
+ * pull off the URL, and the notification renderer reads it to name the platform.
8
+ * Two lists would drift into a cookie holding a click ID nothing can attribute.
9
+ */
10
+ /**
11
+ * Click-ID parameters, in identification precedence.
12
+ *
13
+ * Order matters: `identifyPlatform` and `getPrimaryClickId` return the first
14
+ * match, and a URL can legitimately carry more than one (a Google Ads link
15
+ * retargeted through Meta arrives with both `gclid` and `fbclid`). Google first
16
+ * because its click IDs are the ones that survive redirects intact.
17
+ */
18
+ const PLATFORM_PARAMS = {
19
+ gclid: "Google Ads",
20
+ gbraid: "Google Ads",
21
+ wbraid: "Google Ads",
22
+ fbclid: "Meta",
23
+ ttclid: "TikTok",
24
+ rdt_cid: "Reddit",
25
+ ScCid: "Snapchat",
26
+ msclkid: "Microsoft Ads"
27
+ };
28
+ /** Just the parameter names, in the same precedence order. */
29
+ const CLICK_ID_PARAMS = Object.keys(PLATFORM_PARAMS);
30
+ const UTM_PARAMS = [
31
+ "utm_source",
32
+ "utm_medium",
33
+ "utm_campaign",
34
+ "utm_content",
35
+ "utm_term"
36
+ ];
37
+ /** Everything the capture script pulls off the landing URL. */
38
+ const TRACKED_PARAMS = [...CLICK_ID_PARAMS, ...UTM_PARAMS];
39
+ /**
40
+ * The attribution cookie.
41
+ *
42
+ * First-party and same-site: it is read by this site's own conversion handler and
43
+ * never sent anywhere. It is still a persistent identifier, which is why writing
44
+ * it waits on consent.
45
+ */
46
+ const TRACKING_COOKIE = "_ac";
47
+ /** 90 days — an ad click and the conversion it produces are rarely further apart. */
48
+ const TRACKING_COOKIE_DAYS = 90;
49
+ /** The A/B variant cookie, assigned by the router when a site runs experiments. */
50
+ const AB_COOKIE = "_ab";
51
+
52
+ //#endregion
53
+ //#region src/tracking/platform.ts
54
+ /**
55
+ * Naming the source of a visit.
56
+ *
57
+ * Both functions return the **first** match in `PLATFORM_PARAMS` order rather
58
+ * than collecting every one, because a notification needs an answer, not a list.
59
+ * A URL carrying two click IDs is a real case — a Google Ads link retargeted
60
+ * through Meta — and the precedence in the table is the tiebreak.
61
+ */
62
+ /**
63
+ * Which platform sent this visitor.
64
+ *
65
+ * Falls back to `utm_source` when no click ID is present: a UTM-tagged link from
66
+ * a newsletter or a partner site is still attribution, just self-declared rather
67
+ * than platform-issued. Returns null when there is nothing to go on.
68
+ */
69
+ function identifyPlatform(data) {
70
+ for (const param of CLICK_ID_PARAMS) if (data[param]) return PLATFORM_PARAMS[param] ?? null;
71
+ const source = data["utm_source"];
72
+ return typeof source === "string" && source ? source : null;
73
+ }
74
+ /** The click ID to quote, with the parameter it came from. */
75
+ function getPrimaryClickId(data) {
76
+ for (const param of CLICK_ID_PARAMS) {
77
+ const value = data[param];
78
+ if (value) return {
79
+ param,
80
+ value: String(value)
81
+ };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ //#endregion
87
+ export { PLATFORM_PARAMS as a, TRACKING_COOKIE_DAYS as c, CLICK_ID_PARAMS as i, UTM_PARAMS as l, identifyPlatform as n, TRACKED_PARAMS as o, AB_COOKIE as r, TRACKING_COOKIE as s, getPrimaryClickId as t };
88
+ //# sourceMappingURL=platform-CIdwwI_Z.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform-CIdwwI_Z.mjs","names":[],"sources":["../src/tracking/params.ts","../src/tracking/platform.ts"],"sourcesContent":["/**\n * The ad-platform vocabulary — the one place that says which URL parameters mean\n * \"this visit came from an ad\", and which platform each belongs to.\n *\n * Single source of truth on purpose: the capture script reads it to know what to\n * pull off the URL, and the notification renderer reads it to name the platform.\n * Two lists would drift into a cookie holding a click ID nothing can attribute.\n */\n\n/**\n * Click-ID parameters, in identification precedence.\n *\n * Order matters: `identifyPlatform` and `getPrimaryClickId` return the first\n * match, and a URL can legitimately carry more than one (a Google Ads link\n * retargeted through Meta arrives with both `gclid` and `fbclid`). Google first\n * because its click IDs are the ones that survive redirects intact.\n */\nexport const PLATFORM_PARAMS: Readonly<Record<string, string>> = {\n gclid: 'Google Ads',\n gbraid: 'Google Ads',\n wbraid: 'Google Ads',\n fbclid: 'Meta',\n ttclid: 'TikTok',\n rdt_cid: 'Reddit',\n ScCid: 'Snapchat',\n msclkid: 'Microsoft Ads',\n};\n\n/** Just the parameter names, in the same precedence order. */\nexport const CLICK_ID_PARAMS: readonly string[] = Object.keys(PLATFORM_PARAMS);\n\nexport const UTM_PARAMS = [\n 'utm_source',\n 'utm_medium',\n 'utm_campaign',\n 'utm_content',\n 'utm_term',\n] as const;\n\n/** Everything the capture script pulls off the landing URL. */\nexport const TRACKED_PARAMS: readonly string[] = [...CLICK_ID_PARAMS, ...UTM_PARAMS];\n\n/**\n * The attribution cookie.\n *\n * First-party and same-site: it is read by this site's own conversion handler and\n * never sent anywhere. It is still a persistent identifier, which is why writing\n * it waits on consent.\n */\nexport const TRACKING_COOKIE = '_ac';\n\n/** 90 days — an ad click and the conversion it produces are rarely further apart. */\nexport const TRACKING_COOKIE_DAYS = 90;\n\n/** The A/B variant cookie, assigned by the router when a site runs experiments. */\nexport const AB_COOKIE = '_ab';\n","/**\n * Naming the source of a visit.\n *\n * Both functions return the **first** match in `PLATFORM_PARAMS` order rather\n * than collecting every one, because a notification needs an answer, not a list.\n * A URL carrying two click IDs is a real case — a Google Ads link retargeted\n * through Meta — and the precedence in the table is the tiebreak.\n */\nimport { CLICK_ID_PARAMS, PLATFORM_PARAMS } from './params.ts';\nimport type { TrackingData } from './types.ts';\n\n/**\n * Which platform sent this visitor.\n *\n * Falls back to `utm_source` when no click ID is present: a UTM-tagged link from\n * a newsletter or a partner site is still attribution, just self-declared rather\n * than platform-issued. Returns null when there is nothing to go on.\n */\nexport function identifyPlatform(data: TrackingData): string | null {\n for (const param of CLICK_ID_PARAMS) {\n if (data[param]) return PLATFORM_PARAMS[param] ?? null;\n }\n const source = data['utm_source'];\n return typeof source === 'string' && source ? source : null;\n}\n\n/** The click ID to quote, with the parameter it came from. */\nexport function getPrimaryClickId(data: TrackingData): { param: string; value: string } | null {\n for (const param of CLICK_ID_PARAMS) {\n const value = data[param];\n if (value) return { param, value: String(value) };\n }\n return null;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,MAAa,kBAAoD;CAC/D,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,SAAS;CACT,OAAO;CACP,SAAS;AACX;;AAGA,MAAa,kBAAqC,OAAO,KAAK,eAAe;AAE7E,MAAa,aAAa;CACxB;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAa,iBAAoC,CAAC,GAAG,iBAAiB,GAAG,UAAU;;;;;;;;AASnF,MAAa,kBAAkB;;AAG/B,MAAa,uBAAuB;;AAGpC,MAAa,YAAY;;;;;;;;;;;;;;;;;;;ACrCzB,SAAgB,iBAAiB,MAAmC;CAClE,KAAK,MAAM,SAAS,iBAClB,IAAI,KAAK,QAAQ,OAAO,gBAAgB,UAAU;CAEpD,MAAM,SAAS,KAAK;CACpB,OAAO,OAAO,WAAW,YAAY,SAAS,SAAS;AACzD;;AAGA,SAAgB,kBAAkB,MAA6D;CAC7F,KAAK,MAAM,SAAS,iBAAiB;EACnC,MAAM,QAAQ,KAAK;EACnB,IAAI,OAAO,OAAO;GAAE;GAAO,OAAO,OAAO,KAAK;EAAE;CAClD;CACA,OAAO;AACT"}
@@ -0,0 +1,46 @@
1
+ //#region src/google/token.d.ts
2
+ /** The fields we need from a service-account JSON key. */
3
+ interface ServiceAccount {
4
+ client_email: string;
5
+ private_key: string;
6
+ }
7
+ /** A user OAuth credential (refresh-token flow — never a service account). */
8
+ interface GoogleOAuth {
9
+ clientId: string;
10
+ clientSecret: string;
11
+ refreshToken: string;
12
+ }
13
+ /**
14
+ * Which identity to act as.
15
+ *
16
+ * `scope` rides on the service-account variant because that grant carries its
17
+ * scope in the signed assertion. A user OAuth token's scopes are fixed when the
18
+ * refresh token is minted, so there is nothing to pass at exchange time — the
19
+ * asymmetry is the protocol's, not ours.
20
+ */
21
+ type GoogleCredential = {
22
+ kind: 'service-account';
23
+ account: ServiceAccount;
24
+ scope: string;
25
+ } | {
26
+ kind: 'oauth';
27
+ oauth: GoogleOAuth;
28
+ };
29
+ /**
30
+ * Exchange either credential for a short-lived access token.
31
+ *
32
+ * Throws rather than returning a result: nothing downstream can run without a
33
+ * token, so there is no per-item decision for a caller to make. (The API calls
34
+ * themselves do return structured results — those fail per domain.)
35
+ *
36
+ * `now` is a parameter so the assertion is testable without mocking the clock.
37
+ */
38
+ declare function googleAccessToken(credential: GoogleCredential, fetchImpl?: typeof fetch, now?: number): Promise<string>;
39
+ /** Scopes, named once so a caller never spells one out. */
40
+ declare const SCOPES: {
41
+ /** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */readonly webmasters: "https://www.googleapis.com/auth/webmasters"; /** Site Verification API. */
42
+ readonly siteVerification: "https://www.googleapis.com/auth/siteverification";
43
+ };
44
+ //#endregion
45
+ export { googleAccessToken as a, ServiceAccount as i, GoogleOAuth as n, SCOPES as r, GoogleCredential as t };
46
+ //# sourceMappingURL=token--18wN9UR.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token--18wN9UR.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;;cAiBU,MAAA;EAnBX,6EAwBQ,UAAA,gDAtBP;EAAA,SAsBO,gBAAA;AAAA"}