@getvitops/utils 3.0.0 → 4.1.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 +139 -0
- package/README.md +1 -1
- package/dist/ads/index.d.mts +228 -0
- package/dist/ads/index.d.mts.map +1 -0
- package/dist/ads/index.mjs +380 -0
- package/dist/ads/index.mjs.map +1 -0
- package/dist/cloudflare-BW-Iyi-B.mjs +92 -0
- package/dist/cloudflare-BW-Iyi-B.mjs.map +1 -0
- package/dist/cloudflare-BiK61Oq4.d.mts +27 -0
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -0
- package/dist/indexing/index.d.mts +4 -7
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +13 -37
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +207 -0
- package/dist/notify/index.d.mts.map +1 -0
- package/dist/notify/index.mjs +320 -0
- package/dist/notify/index.mjs.map +1 -0
- package/dist/onboarding/index.d.mts +207 -0
- package/dist/onboarding/index.d.mts.map +1 -0
- package/dist/onboarding/index.mjs +359 -0
- package/dist/onboarding/index.mjs.map +1 -0
- package/dist/platform-0nlOIy8h.mjs +96 -0
- package/dist/platform-0nlOIy8h.mjs.map +1 -0
- package/dist/token--18wN9UR.d.mts +46 -0
- package/dist/token--18wN9UR.d.mts.map +1 -0
- package/dist/token-BV7tPHh2.mjs +96 -0
- package/dist/token-BV7tPHh2.mjs.map +1 -0
- package/dist/tracking/index.d.mts +94 -0
- package/dist/tracking/index.d.mts.map +1 -0
- package/dist/tracking/index.mjs +77 -0
- package/dist/tracking/index.mjs.map +1 -0
- package/dist/types-C3lHaSrU.d.mts +69 -0
- package/dist/types-C3lHaSrU.d.mts.map +1 -0
- package/package.json +17 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { n as googleAccessToken } from "../token-BV7tPHh2.mjs";
|
|
2
|
+
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BW-Iyi-B.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/onboarding/plan.ts
|
|
5
|
+
/** The `sc-domain:` property identifier for a bare domain. */
|
|
6
|
+
const siteUrlFor = (domain) => `sc-domain:${domain}`;
|
|
7
|
+
/**
|
|
8
|
+
* Backoff ladder for the verification retry, in milliseconds.
|
|
9
|
+
*
|
|
10
|
+
* DNS_TXT verification fails until the record has propagated, which is a matter of
|
|
11
|
+
* seconds-to-minutes, not a real error — so the schedule is a *policy*, decided
|
|
12
|
+
* here and asserted in the tests, while the actual sleeping is the CLI's job.
|
|
13
|
+
* Capped at `maxAttempts`; a domain still unverified after the last attempt is
|
|
14
|
+
* reported PENDING, never failed.
|
|
15
|
+
*/
|
|
16
|
+
function backoffSchedule(maxAttempts = 5) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1e3);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Decide the four steps for one domain from its desired config and live state. */
|
|
22
|
+
function planDomain(setup, state) {
|
|
23
|
+
const reminders = [];
|
|
24
|
+
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`);
|
|
25
|
+
const txt = state.txtPresent ? {
|
|
26
|
+
action: "skip",
|
|
27
|
+
detail: "apex TXT already present"
|
|
28
|
+
} : {
|
|
29
|
+
action: "create",
|
|
30
|
+
detail: "create apex TXT verification record"
|
|
31
|
+
};
|
|
32
|
+
const verify = state.verified ? {
|
|
33
|
+
action: "skip",
|
|
34
|
+
detail: "ownership already verified"
|
|
35
|
+
} : {
|
|
36
|
+
action: "create",
|
|
37
|
+
detail: "verify ownership (DNS_TXT)"
|
|
38
|
+
};
|
|
39
|
+
const property = state.propertyExists ? {
|
|
40
|
+
action: "skip",
|
|
41
|
+
detail: "property already exists"
|
|
42
|
+
} : {
|
|
43
|
+
action: "create",
|
|
44
|
+
detail: `add property ${siteUrlFor(setup.domain)}`
|
|
45
|
+
};
|
|
46
|
+
const desired = setup.delegatedOwners ?? [];
|
|
47
|
+
const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));
|
|
48
|
+
const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));
|
|
49
|
+
const owners = desired.length === 0 ? {
|
|
50
|
+
action: "skip",
|
|
51
|
+
detail: "no delegated owners"
|
|
52
|
+
} : ownersToAdd.length === 0 ? {
|
|
53
|
+
action: "skip",
|
|
54
|
+
detail: "all delegated owners already present"
|
|
55
|
+
} : {
|
|
56
|
+
action: "update",
|
|
57
|
+
detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(", ")}`
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
domain: setup.domain,
|
|
61
|
+
siteUrl: siteUrlFor(setup.domain),
|
|
62
|
+
txt,
|
|
63
|
+
verify,
|
|
64
|
+
property,
|
|
65
|
+
owners,
|
|
66
|
+
ownersToAdd,
|
|
67
|
+
reminders
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The owner set to PUT: everyone already there, plus everyone being added.
|
|
72
|
+
*
|
|
73
|
+
* `updateOwners` REPLACES the owner list, so this is the one computation standing
|
|
74
|
+
* between a delegation and locking yourself out — pass a bare `ownersToAdd` and
|
|
75
|
+
* the account that just verified the domain is removed from it. It belongs here
|
|
76
|
+
* rather than at the call site for exactly that reason: it is a decision, it is
|
|
77
|
+
* the dangerous one, and the executor is not allowed to make it.
|
|
78
|
+
*
|
|
79
|
+
* `current` comes FIRST so the API's own casing survives dedupe; matching is
|
|
80
|
+
* case-insensitive, mirroring `planDomain`'s `present` check, because Google
|
|
81
|
+
* echoes back addresses in whatever case the account uses and a case-sensitive
|
|
82
|
+
* `Set` would re-add an owner who is already there.
|
|
83
|
+
*/
|
|
84
|
+
function ownerUnion(current, toAdd) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const seen = /* @__PURE__ */ new Set();
|
|
87
|
+
for (const o of [...current, ...toAdd]) {
|
|
88
|
+
const key = o.toLowerCase();
|
|
89
|
+
if (seen.has(key)) continue;
|
|
90
|
+
seen.add(key);
|
|
91
|
+
out.push(o);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** Plan every domain. */
|
|
96
|
+
function plan(config, states) {
|
|
97
|
+
const notes = [];
|
|
98
|
+
return {
|
|
99
|
+
domains: config.domains.map((setup) => {
|
|
100
|
+
const state = states.get(setup.domain);
|
|
101
|
+
if (!state) notes.push(`${setup.domain}: no observed state; treating everything as pending`);
|
|
102
|
+
return planDomain(setup, state ?? {
|
|
103
|
+
txtPresent: false,
|
|
104
|
+
verified: false,
|
|
105
|
+
currentOwners: [],
|
|
106
|
+
propertyExists: false
|
|
107
|
+
});
|
|
108
|
+
}),
|
|
109
|
+
notes
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Any step on any domain is a mutation — i.e. the live state has drifted. */
|
|
113
|
+
function hasDrift(p) {
|
|
114
|
+
return p.domains.some((d) => d.txt.action !== "skip" || d.verify.action !== "skip" || d.property.action !== "skip" || d.owners.action !== "skip");
|
|
115
|
+
}
|
|
116
|
+
/** Render a plan as the block `--dry` and `--check` print. */
|
|
117
|
+
function formatPlan(p) {
|
|
118
|
+
const lines = [];
|
|
119
|
+
const glyph = (s) => s.action === "skip" ? "·" : "+";
|
|
120
|
+
for (const d of p.domains) {
|
|
121
|
+
lines.push(d.domain);
|
|
122
|
+
lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);
|
|
123
|
+
lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);
|
|
124
|
+
lines.push(` ${glyph(d.property)} property ${d.property.detail}`);
|
|
125
|
+
lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);
|
|
126
|
+
for (const r of d.reminders) lines.push(` ! ${r}`);
|
|
127
|
+
}
|
|
128
|
+
for (const n of p.notes) lines.push(`! ${n}`);
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
const pad = (s, n) => s.padEnd(n);
|
|
132
|
+
/** Render the end-of-run summary: one row per domain, plus any reminders. */
|
|
133
|
+
function formatSummary(results) {
|
|
134
|
+
const headers = [
|
|
135
|
+
"DOMAIN",
|
|
136
|
+
"TXT",
|
|
137
|
+
"VERIFIED",
|
|
138
|
+
"PROPERTY"
|
|
139
|
+
];
|
|
140
|
+
const rows = results.map((r) => [
|
|
141
|
+
r.domain,
|
|
142
|
+
r.txt,
|
|
143
|
+
r.verified,
|
|
144
|
+
r.property
|
|
145
|
+
]);
|
|
146
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
147
|
+
const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
|
|
148
|
+
const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
|
|
149
|
+
for (const row of rows) out.push(line(row));
|
|
150
|
+
const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));
|
|
151
|
+
if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
|
|
152
|
+
return out.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/onboarding/google.ts
|
|
157
|
+
/**
|
|
158
|
+
* Google — the two APIs onboarding drives, and the user-OAuth token behind both.
|
|
159
|
+
*
|
|
160
|
+
* Auth here is a **user OAuth refresh token**, not a service account: verifying a
|
|
161
|
+
* site and adding a Search Console property act *as a person* who can own them, not
|
|
162
|
+
* as a project robot. That makes the token exchange simpler than the indexing
|
|
163
|
+
* module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs
|
|
164
|
+
* broader scope, granted when the refresh token is minted:
|
|
165
|
+
* - `https://www.googleapis.com/auth/siteverification` (Site Verification API)
|
|
166
|
+
* - `https://www.googleapis.com/auth/webmasters` (Search Console API)
|
|
167
|
+
*
|
|
168
|
+
* The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`
|
|
169
|
+
* — the grants differ, everything after building the form body did not. It throws
|
|
170
|
+
* (nothing downstream can run without a token) while every API call below returns
|
|
171
|
+
* a structured result and lets the caller decide per domain. `googleapis` is
|
|
172
|
+
* deliberately not a dependency — these are a handful of REST endpoints in a CLI
|
|
173
|
+
* that installs into every consumer.
|
|
174
|
+
*/
|
|
175
|
+
const SITEVERIFICATION = "https://www.googleapis.com/siteVerification/v1";
|
|
176
|
+
const WEBMASTERS = "https://www.googleapis.com/webmasters/v3";
|
|
177
|
+
/** Exchange a user OAuth refresh token for a short-lived access token. */
|
|
178
|
+
function refreshTokenGrant(oauth, fetchImpl = fetch) {
|
|
179
|
+
return googleAccessToken({
|
|
180
|
+
kind: "oauth",
|
|
181
|
+
oauth
|
|
182
|
+
}, fetchImpl);
|
|
183
|
+
}
|
|
184
|
+
const inetSite = (domain) => ({
|
|
185
|
+
type: "INET_DOMAIN",
|
|
186
|
+
identifier: domain
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* Ask the Site Verification API for the DNS_TXT token for a domain.
|
|
190
|
+
*
|
|
191
|
+
* Returns the full `google-site-verification=…` value that goes in the apex TXT
|
|
192
|
+
* record. It is deterministic per (user, domain), so re-fetching it and comparing
|
|
193
|
+
* against DNS is what makes the TXT step idempotent.
|
|
194
|
+
*/
|
|
195
|
+
async function getVerificationToken(token, domain, fetchImpl = fetch) {
|
|
196
|
+
const res = await fetchImpl(`${SITEVERIFICATION}/token`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
authorization: `Bearer ${token}`,
|
|
200
|
+
"content-type": "application/json"
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
verificationMethod: "DNS_TXT",
|
|
204
|
+
site: inetSite(domain)
|
|
205
|
+
})
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) return {
|
|
208
|
+
ok: false,
|
|
209
|
+
status: res.status,
|
|
210
|
+
message: await res.text()
|
|
211
|
+
};
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
if (!body.token) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
status: res.status,
|
|
216
|
+
message: "no token in response"
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
status: res.status,
|
|
221
|
+
token: body.token
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Look up the verified web resource for a domain, if any.
|
|
226
|
+
*
|
|
227
|
+
* Uses `webResource.list` rather than a `get` by id, because the id format for an
|
|
228
|
+
* INET_DOMAIN resource is awkward to construct by hand and the list only ever
|
|
229
|
+
* contains resources the user already owns — so a hit *is* the verified state, and
|
|
230
|
+
* carries the id and owners the later steps need.
|
|
231
|
+
*/
|
|
232
|
+
async function getWebResource(token, domain, fetchImpl = fetch) {
|
|
233
|
+
const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers: { authorization: `Bearer ${token}` } });
|
|
234
|
+
if (!res.ok) return {
|
|
235
|
+
ok: false,
|
|
236
|
+
status: res.status,
|
|
237
|
+
exists: false,
|
|
238
|
+
owners: [],
|
|
239
|
+
message: await res.text()
|
|
240
|
+
};
|
|
241
|
+
const match = (await res.json()).items?.find((it) => it.site?.type === "INET_DOMAIN" && it.site?.identifier === domain);
|
|
242
|
+
if (!match) return {
|
|
243
|
+
ok: true,
|
|
244
|
+
status: res.status,
|
|
245
|
+
exists: false,
|
|
246
|
+
owners: []
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
status: res.status,
|
|
251
|
+
exists: true,
|
|
252
|
+
...match.id ? { id: match.id } : {},
|
|
253
|
+
owners: match.owners ?? []
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
|
|
257
|
+
async function verifyWebResource(token, domain, fetchImpl = fetch) {
|
|
258
|
+
const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: {
|
|
261
|
+
authorization: `Bearer ${token}`,
|
|
262
|
+
"content-type": "application/json"
|
|
263
|
+
},
|
|
264
|
+
body: JSON.stringify({
|
|
265
|
+
site: inetSite(domain),
|
|
266
|
+
verificationMethod: "DNS_TXT"
|
|
267
|
+
})
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok) return {
|
|
270
|
+
ok: false,
|
|
271
|
+
status: res.status,
|
|
272
|
+
owners: [],
|
|
273
|
+
message: await res.text()
|
|
274
|
+
};
|
|
275
|
+
const body = await res.json();
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
status: res.status,
|
|
279
|
+
...body.id ? { id: body.id } : {},
|
|
280
|
+
owners: body.owners ?? []
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Set the owners of a verified web resource to a given list.
|
|
285
|
+
*
|
|
286
|
+
* The API replaces the owner set, so the caller passes the **union** of the
|
|
287
|
+
* current owners and the ones to add — never a bare list of new owners, which
|
|
288
|
+
* would drop everyone already there.
|
|
289
|
+
*/
|
|
290
|
+
async function updateOwners(token, id, domain, owners, fetchImpl = fetch) {
|
|
291
|
+
const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {
|
|
292
|
+
method: "PUT",
|
|
293
|
+
headers: {
|
|
294
|
+
authorization: `Bearer ${token}`,
|
|
295
|
+
"content-type": "application/json"
|
|
296
|
+
},
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
id,
|
|
299
|
+
site: inetSite(domain),
|
|
300
|
+
owners
|
|
301
|
+
})
|
|
302
|
+
});
|
|
303
|
+
if (!res.ok) return {
|
|
304
|
+
ok: false,
|
|
305
|
+
status: res.status,
|
|
306
|
+
message: await res.text()
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
status: res.status
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
|
|
314
|
+
/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
|
|
315
|
+
async function getSite(token, siteUrl, fetchImpl = fetch) {
|
|
316
|
+
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: { authorization: `Bearer ${token}` } });
|
|
317
|
+
if (res.ok) return {
|
|
318
|
+
ok: true,
|
|
319
|
+
status: res.status,
|
|
320
|
+
exists: true
|
|
321
|
+
};
|
|
322
|
+
if (res.status === 404) return {
|
|
323
|
+
ok: true,
|
|
324
|
+
status: res.status,
|
|
325
|
+
exists: false
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
status: res.status,
|
|
330
|
+
exists: false,
|
|
331
|
+
message: await res.text()
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Add a Search Console property (`PUT sites/{siteUrl}`).
|
|
336
|
+
*
|
|
337
|
+
* A `403` here almost always means the domain is not yet verified for this user
|
|
338
|
+
* rather than a transient error — property creation is gated on ownership.
|
|
339
|
+
*/
|
|
340
|
+
async function addSite(token, siteUrl, fetchImpl = fetch) {
|
|
341
|
+
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {
|
|
342
|
+
method: "PUT",
|
|
343
|
+
headers: { authorization: `Bearer ${token}` }
|
|
344
|
+
});
|
|
345
|
+
if (res.ok) return {
|
|
346
|
+
ok: true,
|
|
347
|
+
status: res.status
|
|
348
|
+
};
|
|
349
|
+
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()}`;
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: res.status,
|
|
353
|
+
message: detail
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
export { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan, formatSummary, getSite, getVerificationToken, getWebResource, hasDrift, listApexTxt, ownerUnion, plan, planDomain, refreshTokenGrant, siteUrlFor, updateOwners, verifyWebResource };
|
|
359
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
* This table must cover every `clickIdParams` entry in `ads/providers.ts`, and
|
|
19
|
+
* `ads/providers.test.ts` asserts it does. A configured ad property whose click ID
|
|
20
|
+
* is not captured here means every conversion from that platform arrives
|
|
21
|
+
* unattributed — silently, since an unattributed conversion looks exactly like an
|
|
22
|
+
* organic one. `li_fat_id` and `epik` were missing for precisely that reason.
|
|
23
|
+
*/
|
|
24
|
+
const PLATFORM_PARAMS = {
|
|
25
|
+
gclid: "Google Ads",
|
|
26
|
+
gbraid: "Google Ads",
|
|
27
|
+
wbraid: "Google Ads",
|
|
28
|
+
fbclid: "Meta",
|
|
29
|
+
ttclid: "TikTok",
|
|
30
|
+
rdt_cid: "Reddit",
|
|
31
|
+
ScCid: "Snapchat",
|
|
32
|
+
msclkid: "Microsoft Ads",
|
|
33
|
+
li_fat_id: "LinkedIn",
|
|
34
|
+
epik: "Pinterest"
|
|
35
|
+
};
|
|
36
|
+
/** Just the parameter names, in the same precedence order. */
|
|
37
|
+
const CLICK_ID_PARAMS = Object.keys(PLATFORM_PARAMS);
|
|
38
|
+
const UTM_PARAMS = [
|
|
39
|
+
"utm_source",
|
|
40
|
+
"utm_medium",
|
|
41
|
+
"utm_campaign",
|
|
42
|
+
"utm_content",
|
|
43
|
+
"utm_term"
|
|
44
|
+
];
|
|
45
|
+
/** Everything the capture script pulls off the landing URL. */
|
|
46
|
+
const TRACKED_PARAMS = [...CLICK_ID_PARAMS, ...UTM_PARAMS];
|
|
47
|
+
/**
|
|
48
|
+
* The attribution cookie.
|
|
49
|
+
*
|
|
50
|
+
* First-party and same-site: it is read by this site's own conversion handler and
|
|
51
|
+
* never sent anywhere. It is still a persistent identifier, which is why writing
|
|
52
|
+
* it waits on consent.
|
|
53
|
+
*/
|
|
54
|
+
const TRACKING_COOKIE = "_ac";
|
|
55
|
+
/** 90 days — an ad click and the conversion it produces are rarely further apart. */
|
|
56
|
+
const TRACKING_COOKIE_DAYS = 90;
|
|
57
|
+
/** The A/B variant cookie, assigned by the router when a site runs experiments. */
|
|
58
|
+
const AB_COOKIE = "_ab";
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/tracking/platform.ts
|
|
62
|
+
/**
|
|
63
|
+
* Naming the source of a visit.
|
|
64
|
+
*
|
|
65
|
+
* Both functions return the **first** match in `PLATFORM_PARAMS` order rather
|
|
66
|
+
* than collecting every one, because a notification needs an answer, not a list.
|
|
67
|
+
* A URL carrying two click IDs is a real case — a Google Ads link retargeted
|
|
68
|
+
* through Meta — and the precedence in the table is the tiebreak.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Which platform sent this visitor.
|
|
72
|
+
*
|
|
73
|
+
* Falls back to `utm_source` when no click ID is present: a UTM-tagged link from
|
|
74
|
+
* a newsletter or a partner site is still attribution, just self-declared rather
|
|
75
|
+
* than platform-issued. Returns null when there is nothing to go on.
|
|
76
|
+
*/
|
|
77
|
+
function identifyPlatform(data) {
|
|
78
|
+
for (const param of CLICK_ID_PARAMS) if (data[param]) return PLATFORM_PARAMS[param] ?? null;
|
|
79
|
+
const source = data["utm_source"];
|
|
80
|
+
return typeof source === "string" && source ? source : null;
|
|
81
|
+
}
|
|
82
|
+
/** The click ID to quote, with the parameter it came from. */
|
|
83
|
+
function getPrimaryClickId(data) {
|
|
84
|
+
for (const param of CLICK_ID_PARAMS) {
|
|
85
|
+
const value = data[param];
|
|
86
|
+
if (value) return {
|
|
87
|
+
param,
|
|
88
|
+
value: String(value)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
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 };
|
|
96
|
+
//# sourceMappingURL=platform-0nlOIy8h.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform-0nlOIy8h.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 *\n * This table must cover every `clickIdParams` entry in `ads/providers.ts`, and\n * `ads/providers.test.ts` asserts it does. A configured ad property whose click ID\n * is not captured here means every conversion from that platform arrives\n * unattributed — silently, since an unattributed conversion looks exactly like an\n * organic one. `li_fat_id` and `epik` were missing for precisely that reason.\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 li_fat_id: 'LinkedIn',\n epik: 'Pinterest',\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":";;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,kBAAoD;CAC/D,OAAO;CACP,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,SAAS;CACT,OAAO;CACP,SAAS;CACT,WAAW;CACX,MAAM;AACR;;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;;;;;;;;;;;;;;;;;;;AC7CzB,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"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/google/token.ts
|
|
4
|
+
/**
|
|
5
|
+
* Google OAuth2 token exchange — one request, two grants.
|
|
6
|
+
*
|
|
7
|
+
* Both of this package's Google modules need a bearer token, and both used to
|
|
8
|
+
* mint one themselves. The two copies looked different because the *credentials*
|
|
9
|
+
* differ, but everything after building the form body was identical: same
|
|
10
|
+
* endpoint, same content-type, same "surface the response body as the error"
|
|
11
|
+
* rule, same `access_token` extraction. So the seam is precise —
|
|
12
|
+
* **credential → form params differs, params → token does not.**
|
|
13
|
+
*
|
|
14
|
+
* The two grants are kept because they are not accidental duplication; they track
|
|
15
|
+
* how each command is actually run:
|
|
16
|
+
*
|
|
17
|
+
* - **service account** (RS256 JWT bearer) for `search notify`, which runs on
|
|
18
|
+
* every deploy in CI. It never expires. A refresh token can be revoked, and
|
|
19
|
+
* for an OAuth client still in *Testing* publishing status Google expires it
|
|
20
|
+
* after 7 days — a bad property for a deploy step.
|
|
21
|
+
* - **user OAuth** (refresh-token grant) for `search setup`, which is a
|
|
22
|
+
* one-time human operation. Site verification makes the calling identity an
|
|
23
|
+
* *owner* of the property, and that should be a person, not a project robot.
|
|
24
|
+
*
|
|
25
|
+
* `googleapis` is deliberately not a dependency — this is one REST endpoint in a
|
|
26
|
+
* CLI that installs into every consumer project. See AGENTS.md.
|
|
27
|
+
*
|
|
28
|
+
* Internal: not a published subpath. Both `./indexing` and `./onboarding` import
|
|
29
|
+
* it, so it lands in a shared chunk, which is free for a Node library. (The
|
|
30
|
+
* "don't create a shared chunk" rule in AGENTS.md is about browser bundles and
|
|
31
|
+
* the consent gate.)
|
|
32
|
+
*/
|
|
33
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
34
|
+
const b64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
35
|
+
/** Sign the RS256 JWT assertion a service-account grant carries. */
|
|
36
|
+
function assertionFor(account, scope, now) {
|
|
37
|
+
const iat = Math.floor(now / 1e3);
|
|
38
|
+
const header = b64url(JSON.stringify({
|
|
39
|
+
alg: "RS256",
|
|
40
|
+
typ: "JWT"
|
|
41
|
+
}));
|
|
42
|
+
const claims = b64url(JSON.stringify({
|
|
43
|
+
iss: account.client_email,
|
|
44
|
+
scope,
|
|
45
|
+
aud: TOKEN_URL,
|
|
46
|
+
iat,
|
|
47
|
+
exp: iat + 3600
|
|
48
|
+
}));
|
|
49
|
+
const signer = createSign("RSA-SHA256");
|
|
50
|
+
signer.update(`${header}.${claims}`);
|
|
51
|
+
const pem = account.private_key.replace(/\\n/g, "\n");
|
|
52
|
+
return `${header}.${claims}.${b64url(signer.sign(pem))}`;
|
|
53
|
+
}
|
|
54
|
+
/** The half that never differs: POST the grant, surface the cause, return the token. */
|
|
55
|
+
async function requestToken(body, fetchImpl) {
|
|
56
|
+
const res = await fetchImpl(TOKEN_URL, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
59
|
+
body: body.toString()
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);
|
|
62
|
+
const { access_token } = await res.json();
|
|
63
|
+
if (!access_token) throw new Error("token exchange returned no access_token");
|
|
64
|
+
return access_token;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Exchange either credential for a short-lived access token.
|
|
68
|
+
*
|
|
69
|
+
* Throws rather than returning a result: nothing downstream can run without a
|
|
70
|
+
* token, so there is no per-item decision for a caller to make. (The API calls
|
|
71
|
+
* themselves do return structured results — those fail per domain.)
|
|
72
|
+
*
|
|
73
|
+
* `now` is a parameter so the assertion is testable without mocking the clock.
|
|
74
|
+
*/
|
|
75
|
+
function googleAccessToken(credential, fetchImpl = fetch, now = Date.now()) {
|
|
76
|
+
return requestToken(credential.kind === "oauth" ? new URLSearchParams({
|
|
77
|
+
grant_type: "refresh_token",
|
|
78
|
+
client_id: credential.oauth.clientId,
|
|
79
|
+
client_secret: credential.oauth.clientSecret,
|
|
80
|
+
refresh_token: credential.oauth.refreshToken
|
|
81
|
+
}) : new URLSearchParams({
|
|
82
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
83
|
+
assertion: assertionFor(credential.account, credential.scope, now)
|
|
84
|
+
}), fetchImpl);
|
|
85
|
+
}
|
|
86
|
+
/** Scopes, named once so a caller never spells one out. */
|
|
87
|
+
const SCOPES = {
|
|
88
|
+
/** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */
|
|
89
|
+
webmasters: "https://www.googleapis.com/auth/webmasters",
|
|
90
|
+
/** Site Verification API. */
|
|
91
|
+
siteVerification: "https://www.googleapis.com/auth/siteverification"
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { googleAccessToken as n, SCOPES as t };
|
|
96
|
+
//# sourceMappingURL=token-BV7tPHh2.mjs.map
|