@ainyc/canonry 4.53.0 → 4.55.1
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/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +8 -5
- package/assets/assets/{BacklinksPage-DELb5ok3.js → BacklinksPage-DVmaM864.js} +1 -1
- package/assets/assets/{ProjectPage-CM_uQa2L.js → ProjectPage-DtL3LFne.js} +4 -4
- package/assets/assets/{RunRow-aqJEr7XJ.js → RunRow-BRqiLxj2.js} +1 -1
- package/assets/assets/{RunsPage-Dhuj1w72.js → RunsPage-UxZ93-cg.js} +1 -1
- package/assets/assets/{SettingsPage-B2_vxr4y.js → SettingsPage-Cr5_EGbk.js} +1 -1
- package/assets/assets/TrafficPage-CUC_lfTe.js +1 -0
- package/assets/assets/TrafficSourceDetailPage-DARPL2TU.js +1 -0
- package/assets/assets/extract-error-message-DD5MibWI.js +1 -0
- package/assets/assets/{index-BStwmAg6.js → index-nnF1LnyK.js} +60 -60
- package/assets/assets/{server-traffic-D_1gSi-b.js → server-traffic-DjRISEZ-.js} +1 -1
- package/assets/assets/{trash-2-8JiADnUJ.js → trash-2-CJ5M--Le.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-KVE7RLBI.js → chunk-2OI7HFAB.js} +396 -87
- package/dist/{chunk-VZPDBHBW.js → chunk-OFY3Z2F7.js} +8 -4
- package/dist/{chunk-JHAHNKSN.js → chunk-UTM3FPAJ.js} +80 -3
- package/dist/{chunk-J7MX3YOH.js → chunk-ZY3EDW3S.js} +1 -1
- package/dist/cli.js +6 -6
- package/dist/index.d.ts +13 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-NKAEHHJ5.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +14 -13
- package/assets/assets/TrafficPage-BKaiZRIH.js +0 -1
- package/assets/assets/TrafficSourceDetailPage-DXIQ4g9S.js +0 -1
- package/assets/assets/arrow-left-CYjzP3M3.js +0 -1
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
loadConfig,
|
|
7
7
|
loadConfigRaw,
|
|
8
8
|
saveConfigPatch
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-ZY3EDW3S.js";
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
12
12
|
IntelligenceService,
|
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
smoothedRunDelta,
|
|
85
85
|
trafficSources,
|
|
86
86
|
usageCounters
|
|
87
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-UTM3FPAJ.js";
|
|
88
88
|
import {
|
|
89
89
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
90
90
|
AGENT_PROVIDER_IDS,
|
|
@@ -185,6 +185,7 @@ import {
|
|
|
185
185
|
emptyCitationVisibility,
|
|
186
186
|
extractAnswerMentions,
|
|
187
187
|
findDuplicateLocationLabels,
|
|
188
|
+
forbidden,
|
|
188
189
|
formatDate,
|
|
189
190
|
formatDateRange,
|
|
190
191
|
formatDeltaCopy,
|
|
@@ -285,7 +286,7 @@ import {
|
|
|
285
286
|
wordpressSchemaDeployResultDtoSchema,
|
|
286
287
|
wordpressSchemaStatusResultDtoSchema,
|
|
287
288
|
wordpressStatusDtoSchema
|
|
288
|
-
} from "./chunk-
|
|
289
|
+
} from "./chunk-OFY3Z2F7.js";
|
|
289
290
|
|
|
290
291
|
// src/telemetry.ts
|
|
291
292
|
import crypto from "crypto";
|
|
@@ -586,6 +587,12 @@ import fs8 from "fs";
|
|
|
586
587
|
// ../api-routes/src/auth.ts
|
|
587
588
|
import crypto2 from "crypto";
|
|
588
589
|
import { eq } from "drizzle-orm";
|
|
590
|
+
function requireScope(request, scope) {
|
|
591
|
+
const key = request.apiKey;
|
|
592
|
+
if (!key) return;
|
|
593
|
+
if (key.scopes.includes("*") || key.scopes.includes(scope)) return;
|
|
594
|
+
throw forbidden(`This action requires the "${scope}" scope on your API key.`);
|
|
595
|
+
}
|
|
589
596
|
function hashKey(key) {
|
|
590
597
|
return crypto2.createHash("sha256").update(key).digest("hex");
|
|
591
598
|
}
|
|
@@ -645,6 +652,8 @@ async function authPlugin(app, opts = {}) {
|
|
|
645
652
|
throw authRequired();
|
|
646
653
|
}
|
|
647
654
|
app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
|
|
655
|
+
const scopes = Array.isArray(key.scopes) ? key.scopes : [];
|
|
656
|
+
request.apiKey = { id: key.id, name: key.name, scopes };
|
|
648
657
|
});
|
|
649
658
|
}
|
|
650
659
|
|
|
@@ -1653,9 +1662,9 @@ async function runRoutes(app, opts) {
|
|
|
1653
1662
|
});
|
|
1654
1663
|
app.get("/projects/:name/runs/latest", async (request, reply) => {
|
|
1655
1664
|
const project = resolveProject(app.db, request.params.name);
|
|
1656
|
-
const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(eq7(runs.projectId, project.id)).get();
|
|
1665
|
+
const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).get();
|
|
1657
1666
|
const totalRuns = countRow?.count ?? 0;
|
|
1658
|
-
const latestRun = app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt), desc(runs.id)).limit(1).get();
|
|
1667
|
+
const latestRun = app.db.select().from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).orderBy(desc(runs.createdAt), desc(runs.id)).limit(1).get();
|
|
1659
1668
|
if (!latestRun) {
|
|
1660
1669
|
return reply.send({ totalRuns: 0, run: null });
|
|
1661
1670
|
}
|
|
@@ -3816,6 +3825,14 @@ var COLORS = {
|
|
|
3816
3825
|
function escapeHtml(value) {
|
|
3817
3826
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3818
3827
|
}
|
|
3828
|
+
function safeHref(value) {
|
|
3829
|
+
const trimmed = (value ?? "").trim();
|
|
3830
|
+
if (!trimmed) return "#";
|
|
3831
|
+
if (trimmed.startsWith("/")) return escapeHtml(trimmed);
|
|
3832
|
+
if (/^https?:\/\//i.test(trimmed)) return escapeHtml(trimmed);
|
|
3833
|
+
if (/^mailto:/i.test(trimmed)) return escapeHtml(trimmed);
|
|
3834
|
+
return "#";
|
|
3835
|
+
}
|
|
3819
3836
|
function summarizeQueryParams(params) {
|
|
3820
3837
|
const keys = Array.from(params.keys());
|
|
3821
3838
|
const total = keys.length;
|
|
@@ -5075,7 +5092,7 @@ function renderCompetitorLandscape(report) {
|
|
|
5075
5092
|
const mentionCount = mention?.mentionCount ?? 0;
|
|
5076
5093
|
const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
|
|
5077
5094
|
const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
|
|
5078
|
-
<ul>${c.theirCitedPages.map((p) => `<li><a href="${
|
|
5095
|
+
<ul>${c.theirCitedPages.map((p) => `<li><a href="${safeHref(p.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
|
|
5079
5096
|
</details>` : "";
|
|
5080
5097
|
return `<tr>
|
|
5081
5098
|
<td>${escapeHtml(c.domain)}</td>
|
|
@@ -5490,7 +5507,7 @@ function renderServerActivity(report, audience) {
|
|
|
5490
5507
|
<thead><tr><th>AI tool</th><th class="numeric">Bot requests (7d)</th><th class="numeric">User fetches (7d)</th><th class="numeric">Referral sessions</th></tr></thead>
|
|
5491
5508
|
<tbody>${clientOperatorRows}</tbody>
|
|
5492
5509
|
</table>
|
|
5493
|
-
<p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified
|
|
5510
|
+
<p class="meta">Bot requests are bulk crawl (GPTBot, PerplexityBot, \u2026). User fetches are on-demand reads triggered by real users inside an AI surface (ChatGPT-User, Perplexity-User, \u2026). Verified means the request came from an IP the operator publishes as its own; unverified means the user-agent matched but the IP is not in a published range. User-fetch totals count both, since many genuine user fetches come from outside any published range.</p>
|
|
5494
5511
|
</div>` : ""}`
|
|
5495
5512
|
);
|
|
5496
5513
|
}
|
|
@@ -5556,7 +5573,7 @@ function renderServerActivity(report, audience) {
|
|
|
5556
5573
|
</div>
|
|
5557
5574
|
${trendChart}
|
|
5558
5575
|
${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
|
|
5559
|
-
<p class="meta">Verified means
|
|
5576
|
+
<p class="meta">Verified means the request's source IP falls inside the operator's published range. Unverified bots claim the user-agent but the IP is not in a published range, so it could be the real bot or an imitator. User fetches are on-demand reads from an AI surface on behalf of a real user (ChatGPT-User, Perplexity-User, \u2026), disjoint from bulk crawl and counted whether or not the IP can be verified.</p>
|
|
5560
5577
|
<table class="report-table">
|
|
5561
5578
|
<thead><tr><th>Operator</th><th class="numeric">Verified hits</th><th class="numeric">Unverified</th><th class="numeric">User fetches</th><th class="numeric">Referral sessions</th><th class="numeric">7d delta</th></tr></thead>
|
|
5562
5579
|
<tbody>${operatorRows}</tbody>
|
|
@@ -5707,8 +5724,8 @@ function renderOpportunities(report) {
|
|
|
5707
5724
|
</article>`).join("")}
|
|
5708
5725
|
</div>`;
|
|
5709
5726
|
const rows = opps.slice(0, 10).map((o) => {
|
|
5710
|
-
const ourPage = o.ourBestPage ? `<a href="${
|
|
5711
|
-
const winning = o.winningCompetitor ? `<a href="${
|
|
5727
|
+
const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
|
|
5728
|
+
const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
5712
5729
|
const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
|
|
5713
5730
|
return `<tr>
|
|
5714
5731
|
<td>${escapeHtml(o.query)}</td>
|
|
@@ -6026,10 +6043,12 @@ function renderReportHtml(report, opts = {}) {
|
|
|
6026
6043
|
renderRecommendedNextSteps(report)
|
|
6027
6044
|
].join("\n");
|
|
6028
6045
|
const json = escapeJsonForScript(JSON.stringify(report));
|
|
6046
|
+
const csp = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'none'; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
|
6029
6047
|
return `<!DOCTYPE html>
|
|
6030
6048
|
<html lang="en">
|
|
6031
6049
|
<head>
|
|
6032
6050
|
<meta charset="utf-8" />
|
|
6051
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
|
6033
6052
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6034
6053
|
<title>${escapeHtml(title)}</title>
|
|
6035
6054
|
<style>${STYLE}</style>
|
|
@@ -11392,7 +11411,7 @@ var routeCatalog = [
|
|
|
11392
11411
|
method: "post",
|
|
11393
11412
|
path: "/api/v1/projects/{name}/traffic/connect/vercel",
|
|
11394
11413
|
summary: "Connect a Vercel traffic source",
|
|
11395
|
-
description: "Probes Vercel's internal `request-logs` endpoint with the supplied
|
|
11414
|
+
description: "Probes Vercel's internal `request-logs` endpoint with the supplied personal access token (single page, 60-minute window) before persisting. On success, stores the token in `~/.canonry/config.yaml` and creates / updates the project's active Vercel `traffic_sources` row. A probe failure (bad token, wrong project / team id, unreachable host) surfaces as 502 with the upstream status in the message so the caller learns about it up front instead of at the first sync. The project id, team id, and environment are stored as non-secret config on the row; only the personal access token lives in the credential file.",
|
|
11396
11415
|
tags: ["traffic"],
|
|
11397
11416
|
parameters: [nameParameter],
|
|
11398
11417
|
requestBody: {
|
|
@@ -11404,8 +11423,8 @@ var routeCatalog = [
|
|
|
11404
11423
|
required: ["projectId", "teamId", "token"],
|
|
11405
11424
|
properties: {
|
|
11406
11425
|
projectId: { ...stringSchema, description: "Vercel project id (e.g. `prj_...`) \u2014 from the Vercel dashboard or `.vercel/project.json`." },
|
|
11407
|
-
teamId: { ...stringSchema, description: "Vercel team
|
|
11408
|
-
token: { ...stringSchema, description: "Vercel
|
|
11426
|
+
teamId: { ...stringSchema, description: "Vercel team or account id: the org that owns the project (`orgId` in `.vercel/project.json`)." },
|
|
11427
|
+
token: { ...stringSchema, description: "Vercel personal access token. Stored in `~/.canonry/config.yaml`, never the DB or response." },
|
|
11409
11428
|
environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
|
|
11410
11429
|
displayName: stringSchema
|
|
11411
11430
|
}
|
|
@@ -11454,7 +11473,7 @@ var routeCatalog = [
|
|
|
11454
11473
|
method: "post",
|
|
11455
11474
|
path: "/api/v1/projects/{name}/traffic/sources/{id}/backfill",
|
|
11456
11475
|
summary: "Reclassify historical traffic-source logs",
|
|
11457
|
-
description: 'Async one-shot backfill: pulls the last `days` of events (clamped server-side to the upstream retention ceiling \u2014 30d for Cloud Logging `_Default`; the WordPress plugin honours the same window via `since`/`until` query params), classifies them with the current rules, and replaces the hourly rollup buckets + sample slice in the window inside one transaction. Returns immediately with `{ runId, status: "running" }`; poll `GET /runs/{id}` for completion. lastSyncedAt only advances forward, so a backfill never undoes incremental sync progress that ran ahead of it. Supported source types: `cloud-run`, `wordpress`.',
|
|
11476
|
+
description: 'Async one-shot backfill: pulls the last `days` of events (clamped server-side to the upstream retention ceiling \u2014 30d for Cloud Logging `_Default`; the WordPress plugin honours the same window via `since`/`until` query params), classifies them with the current rules, and replaces the hourly rollup buckets + sample slice in the window inside one transaction. Returns immediately with `{ runId, status: "running" }`; poll `GET /runs/{id}` for completion. lastSyncedAt only advances forward, so a backfill never undoes incremental sync progress that ran ahead of it. Supported source types: `cloud-run`, `wordpress`, `vercel`.',
|
|
11458
11477
|
tags: ["traffic"],
|
|
11459
11478
|
parameters: [
|
|
11460
11479
|
nameParameter,
|
|
@@ -11885,6 +11904,7 @@ function buildOperationId(method, path16) {
|
|
|
11885
11904
|
}
|
|
11886
11905
|
|
|
11887
11906
|
// ../api-routes/src/settings.ts
|
|
11907
|
+
var SETTINGS_WRITE_SCOPE = "settings.write";
|
|
11888
11908
|
async function settingsRoutes(app, opts) {
|
|
11889
11909
|
app.get("/settings", async () => ({
|
|
11890
11910
|
providers: opts.providerSummary ?? [],
|
|
@@ -11892,6 +11912,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11892
11912
|
bing: opts.bing ?? { configured: false }
|
|
11893
11913
|
}));
|
|
11894
11914
|
app.put("/settings/providers/:name", async (request) => {
|
|
11915
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11895
11916
|
const { apiKey, baseUrl, model, quota } = request.body ?? {};
|
|
11896
11917
|
const name = request.params.name;
|
|
11897
11918
|
const adapters = opts.providerAdapters ?? [];
|
|
@@ -11950,6 +11971,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11950
11971
|
return result;
|
|
11951
11972
|
});
|
|
11952
11973
|
app.put("/settings/google", async (request) => {
|
|
11974
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11953
11975
|
const { clientId, clientSecret } = request.body ?? {};
|
|
11954
11976
|
if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
|
|
11955
11977
|
throw validationError("clientId and clientSecret are required");
|
|
@@ -11964,6 +11986,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11964
11986
|
return result;
|
|
11965
11987
|
});
|
|
11966
11988
|
app.put("/settings/bing", async (request) => {
|
|
11989
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11967
11990
|
const { apiKey } = request.body ?? {};
|
|
11968
11991
|
if (!apiKey || typeof apiKey !== "string") {
|
|
11969
11992
|
throw validationError("apiKey is required");
|
|
@@ -13482,7 +13505,14 @@ async function googleRoutes(app, opts) {
|
|
|
13482
13505
|
}
|
|
13483
13506
|
const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
|
|
13484
13507
|
const stateEncoded = buildSignedState(
|
|
13485
|
-
{
|
|
13508
|
+
{
|
|
13509
|
+
projectId: project.id,
|
|
13510
|
+
projectName: project.name,
|
|
13511
|
+
domain: project.canonicalDomain,
|
|
13512
|
+
type,
|
|
13513
|
+
propertyId,
|
|
13514
|
+
redirectUri
|
|
13515
|
+
},
|
|
13486
13516
|
stateSecret
|
|
13487
13517
|
);
|
|
13488
13518
|
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
@@ -13529,7 +13559,19 @@ async function googleRoutes(app, opts) {
|
|
|
13529
13559
|
if (!stateData) {
|
|
13530
13560
|
return reply.status(400).send("Invalid or tampered state parameter");
|
|
13531
13561
|
}
|
|
13532
|
-
const { domain, type, propertyId, redirectUri } = stateData;
|
|
13562
|
+
const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
|
|
13563
|
+
if (!projectId) {
|
|
13564
|
+
return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
|
|
13565
|
+
}
|
|
13566
|
+
const project = app.db.select().from(projects).where(eq19(projects.id, projectId)).get();
|
|
13567
|
+
if (!project) {
|
|
13568
|
+
return reply.status(400).send("Project no longer exists. Restart the connect flow.");
|
|
13569
|
+
}
|
|
13570
|
+
if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
|
|
13571
|
+
return reply.status(400).send(
|
|
13572
|
+
`Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
|
|
13573
|
+
);
|
|
13574
|
+
}
|
|
13533
13575
|
let tokens;
|
|
13534
13576
|
try {
|
|
13535
13577
|
tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
@@ -13550,6 +13592,11 @@ async function googleRoutes(app, opts) {
|
|
|
13550
13592
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13551
13593
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
13552
13594
|
const existing = store.getConnection(domain, type);
|
|
13595
|
+
if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
|
|
13596
|
+
return reply.status(403).send(
|
|
13597
|
+
`This domain already has a Google ${String(type).toUpperCase()} connection owned by another project. Disconnect it from that project first (DELETE /api/v1/projects/<owner>/google/connections/${escapeHtml2(String(type))}) before re-connecting from "${escapeHtml2(projectName ?? "")}".`
|
|
13598
|
+
);
|
|
13599
|
+
}
|
|
13553
13600
|
store.upsertConnection({
|
|
13554
13601
|
domain,
|
|
13555
13602
|
connectionType: type,
|
|
@@ -13558,6 +13605,9 @@ async function googleRoutes(app, opts) {
|
|
|
13558
13605
|
refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
|
|
13559
13606
|
tokenExpiresAt: expiresAt,
|
|
13560
13607
|
scopes: tokens.scope?.split(" ") ?? [],
|
|
13608
|
+
// Stamp ownership on first write; subsequent same-project re-connects
|
|
13609
|
+
// preserve it.
|
|
13610
|
+
createdByProjectId: existing?.createdByProjectId ?? projectId,
|
|
13561
13611
|
createdAt: existing?.createdAt ?? now,
|
|
13562
13612
|
updatedAt: now
|
|
13563
13613
|
});
|
|
@@ -13586,16 +13636,26 @@ async function googleRoutes(app, opts) {
|
|
|
13586
13636
|
app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
|
|
13587
13637
|
const store = requireConnectionStore();
|
|
13588
13638
|
const project = resolveProject(app.db, request.params.name);
|
|
13589
|
-
const
|
|
13639
|
+
const type = request.params.type;
|
|
13640
|
+
const existing = store.getConnection(project.canonicalDomain, type);
|
|
13641
|
+
if (!existing) {
|
|
13642
|
+
throw notFound("Google connection", type);
|
|
13643
|
+
}
|
|
13644
|
+
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
13645
|
+
throw validationError(
|
|
13646
|
+
`This Google ${type.toUpperCase()} connection is owned by a different project. Disconnect from the owning project instead.`
|
|
13647
|
+
);
|
|
13648
|
+
}
|
|
13649
|
+
const deleted = store.deleteConnection(project.canonicalDomain, type);
|
|
13590
13650
|
if (!deleted) {
|
|
13591
|
-
throw notFound("Google connection",
|
|
13651
|
+
throw notFound("Google connection", type);
|
|
13592
13652
|
}
|
|
13593
13653
|
writeAuditLog(app.db, {
|
|
13594
13654
|
projectId: project.id,
|
|
13595
13655
|
actor: "api",
|
|
13596
13656
|
action: "google.disconnected",
|
|
13597
13657
|
entityType: "google_connection",
|
|
13598
|
-
entityId:
|
|
13658
|
+
entityId: type
|
|
13599
13659
|
});
|
|
13600
13660
|
return reply.status(204).send();
|
|
13601
13661
|
});
|
|
@@ -14342,10 +14402,16 @@ async function bingRoutes(app, opts) {
|
|
|
14342
14402
|
}
|
|
14343
14403
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
14344
14404
|
const existing = store.getConnection(project.canonicalDomain);
|
|
14405
|
+
if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
14406
|
+
throw validationError(
|
|
14407
|
+
`This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
|
|
14408
|
+
);
|
|
14409
|
+
}
|
|
14345
14410
|
store.upsertConnection({
|
|
14346
14411
|
domain: project.canonicalDomain,
|
|
14347
14412
|
apiKey,
|
|
14348
14413
|
siteUrl: existing?.siteUrl ?? null,
|
|
14414
|
+
createdByProjectId: existing?.createdByProjectId ?? project.id,
|
|
14349
14415
|
createdAt: existing?.createdAt ?? now,
|
|
14350
14416
|
updatedAt: now
|
|
14351
14417
|
});
|
|
@@ -14366,6 +14432,15 @@ async function bingRoutes(app, opts) {
|
|
|
14366
14432
|
app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
|
|
14367
14433
|
const store = requireConnectionStore();
|
|
14368
14434
|
const project = resolveProject(app.db, request.params.name);
|
|
14435
|
+
const existing = store.getConnection(project.canonicalDomain);
|
|
14436
|
+
if (!existing) {
|
|
14437
|
+
throw notFound("Bing connection", project.canonicalDomain);
|
|
14438
|
+
}
|
|
14439
|
+
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
14440
|
+
throw validationError(
|
|
14441
|
+
`This Bing connection is owned by a different project. Disconnect from the owning project instead.`
|
|
14442
|
+
);
|
|
14443
|
+
}
|
|
14369
14444
|
const deleted = store.deleteConnection(project.canonicalDomain);
|
|
14370
14445
|
if (!deleted) {
|
|
14371
14446
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
@@ -14775,6 +14850,7 @@ async function cdpRoutes(app, opts) {
|
|
|
14775
14850
|
return reply.type("image/png").send(stream);
|
|
14776
14851
|
});
|
|
14777
14852
|
app.put("/settings/cdp", async (request, reply) => {
|
|
14853
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
14778
14854
|
if (!opts.onCdpConfigure) {
|
|
14779
14855
|
const err = notImplemented("CDP configuration not supported in this deployment");
|
|
14780
14856
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
@@ -18085,6 +18161,7 @@ async function backlinksRoutes(app, opts) {
|
|
|
18085
18161
|
|
|
18086
18162
|
// ../api-routes/src/traffic.ts
|
|
18087
18163
|
import crypto21 from "crypto";
|
|
18164
|
+
import { Agent as UndiciAgent } from "undici";
|
|
18088
18165
|
import { and as and19, desc as desc13, eq as eq24, gte as gte3, lte as lte2, sql as sql10 } from "drizzle-orm";
|
|
18089
18166
|
|
|
18090
18167
|
// ../integration-cloud-run/src/auth.ts
|
|
@@ -21260,6 +21337,9 @@ function incrementBucket(map, key, fields) {
|
|
|
21260
21337
|
else map.set(key, { fields, hits: 1 });
|
|
21261
21338
|
}
|
|
21262
21339
|
|
|
21340
|
+
// ../integration-wordpress-traffic/src/client.ts
|
|
21341
|
+
import { randomUUID } from "crypto";
|
|
21342
|
+
|
|
21263
21343
|
// ../integration-wordpress-traffic/src/normalize.ts
|
|
21264
21344
|
function trimOrNull(value) {
|
|
21265
21345
|
if (value === null || value === void 0) return null;
|
|
@@ -21290,7 +21370,7 @@ function normalizeWordpressTrafficEvent(event) {
|
|
|
21290
21370
|
queryString,
|
|
21291
21371
|
status: typeof event.status === "number" && Number.isFinite(event.status) ? event.status : null,
|
|
21292
21372
|
userAgent: trimOrNull(event.user_agent),
|
|
21293
|
-
remoteIp: trimOrNull(event.
|
|
21373
|
+
remoteIp: trimOrNull(event.remote_ip),
|
|
21294
21374
|
referer: trimOrNull(event.referer),
|
|
21295
21375
|
latencyMs: null,
|
|
21296
21376
|
requestSizeBytes: null,
|
|
@@ -21361,6 +21441,7 @@ async function listWordpressTrafficEvents(options) {
|
|
|
21361
21441
|
let skippedEntryCount = 0;
|
|
21362
21442
|
let hasMore = false;
|
|
21363
21443
|
const events = [];
|
|
21444
|
+
const dispatcher = options.dispatcher;
|
|
21364
21445
|
for (let page = 0; page < maxPages; page += 1) {
|
|
21365
21446
|
const url = new URL(endpoint);
|
|
21366
21447
|
url.searchParams.set("limit", String(pageSize));
|
|
@@ -21373,14 +21454,20 @@ async function listWordpressTrafficEvents(options) {
|
|
|
21373
21454
|
if (options.until !== void 0 && options.until !== "") {
|
|
21374
21455
|
url.searchParams.set("until", options.until);
|
|
21375
21456
|
}
|
|
21376
|
-
|
|
21457
|
+
url.searchParams.set("_cb", randomUUID());
|
|
21458
|
+
const fetchInit = {
|
|
21377
21459
|
method: "GET",
|
|
21378
21460
|
headers: {
|
|
21379
21461
|
Authorization: authHeader,
|
|
21380
|
-
Accept: "application/json"
|
|
21462
|
+
Accept: "application/json",
|
|
21463
|
+
"Cache-Control": "no-cache"
|
|
21381
21464
|
},
|
|
21382
21465
|
signal: AbortSignal.timeout(timeoutMs)
|
|
21383
|
-
}
|
|
21466
|
+
};
|
|
21467
|
+
if (dispatcher !== void 0) {
|
|
21468
|
+
fetchInit.dispatcher = dispatcher;
|
|
21469
|
+
}
|
|
21470
|
+
const response = await fetch(url, fetchInit);
|
|
21384
21471
|
if (!response.ok) {
|
|
21385
21472
|
const body2 = await readErrorBody2(response);
|
|
21386
21473
|
throw new WordpressTrafficApiError(
|
|
@@ -21594,6 +21681,62 @@ async function listVercelTrafficEvents(options) {
|
|
|
21594
21681
|
};
|
|
21595
21682
|
}
|
|
21596
21683
|
|
|
21684
|
+
// ../integration-vercel/src/drain.ts
|
|
21685
|
+
var MIN_SUB_WINDOW_MS = 6e4;
|
|
21686
|
+
function toMs(value) {
|
|
21687
|
+
return typeof value === "number" ? value : value.getTime();
|
|
21688
|
+
}
|
|
21689
|
+
async function drainVercelTrafficEvents(options) {
|
|
21690
|
+
const startMs = toMs(options.startDate);
|
|
21691
|
+
const endMs = toMs(options.endDate);
|
|
21692
|
+
const events = [];
|
|
21693
|
+
const seenEventIds = /* @__PURE__ */ new Set();
|
|
21694
|
+
if (endMs <= startMs) return { events, subWindowCount: 0 };
|
|
21695
|
+
let cursorMs = startMs;
|
|
21696
|
+
let spanMs = endMs - startMs;
|
|
21697
|
+
let subWindowCount = 0;
|
|
21698
|
+
while (cursorMs < endMs) {
|
|
21699
|
+
if (subWindowCount >= options.maxSubWindows) {
|
|
21700
|
+
throw new Error(
|
|
21701
|
+
`Vercel window not drained within ${options.maxSubWindows} sub-windows \u2014 narrow the time range`
|
|
21702
|
+
);
|
|
21703
|
+
}
|
|
21704
|
+
const subEndMs = Math.min(cursorMs + spanMs, endMs);
|
|
21705
|
+
const page = await options.pull({
|
|
21706
|
+
token: options.token,
|
|
21707
|
+
projectId: options.projectId,
|
|
21708
|
+
teamId: options.teamId,
|
|
21709
|
+
environment: options.environment,
|
|
21710
|
+
startDate: cursorMs,
|
|
21711
|
+
endDate: subEndMs,
|
|
21712
|
+
maxPages: options.pagesPerSubWindow
|
|
21713
|
+
});
|
|
21714
|
+
subWindowCount += 1;
|
|
21715
|
+
if (page.hasMore) {
|
|
21716
|
+
const subSpanMs = subEndMs - cursorMs;
|
|
21717
|
+
if (subSpanMs <= MIN_SUB_WINDOW_MS) {
|
|
21718
|
+
throw new Error(
|
|
21719
|
+
`Vercel window holds more than ${options.pagesPerSubWindow} pages within a ${MIN_SUB_WINDOW_MS / 6e4}-minute slice \u2014 cannot subdivide further`
|
|
21720
|
+
);
|
|
21721
|
+
}
|
|
21722
|
+
spanMs = Math.max(Math.floor(subSpanMs / 2), MIN_SUB_WINDOW_MS);
|
|
21723
|
+
continue;
|
|
21724
|
+
}
|
|
21725
|
+
for (const event of page.events) {
|
|
21726
|
+
if (!seenEventIds.has(event.eventId)) {
|
|
21727
|
+
seenEventIds.add(event.eventId);
|
|
21728
|
+
events.push(event);
|
|
21729
|
+
}
|
|
21730
|
+
}
|
|
21731
|
+
cursorMs = subEndMs;
|
|
21732
|
+
const remainingMs = endMs - cursorMs;
|
|
21733
|
+
if (remainingMs > 0) {
|
|
21734
|
+
spanMs = Math.min(spanMs * 2, remainingMs);
|
|
21735
|
+
}
|
|
21736
|
+
}
|
|
21737
|
+
return { events, subWindowCount };
|
|
21738
|
+
}
|
|
21739
|
+
|
|
21597
21740
|
// ../api-routes/src/traffic.ts
|
|
21598
21741
|
var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
|
|
21599
21742
|
var DEFAULT_PAGE_SIZE3 = 1e3;
|
|
@@ -21602,6 +21745,7 @@ var DEFAULT_SAMPLE_LIMIT2 = 100;
|
|
|
21602
21745
|
var DEFAULT_WP_PAGE_SIZE = 500;
|
|
21603
21746
|
var DEFAULT_WP_MAX_PAGES = 20;
|
|
21604
21747
|
var DEFAULT_VERCEL_MAX_PAGES = 50;
|
|
21748
|
+
var VERCEL_MAX_SUB_WINDOWS = 5e3;
|
|
21605
21749
|
var MAX_TRACKED_EVENT_IDS = 1e3;
|
|
21606
21750
|
var DEFAULT_BACKFILL_DAYS = 30;
|
|
21607
21751
|
var MAX_BACKFILL_DAYS = 90;
|
|
@@ -21807,6 +21951,30 @@ async function trafficRoutes(app, opts) {
|
|
|
21807
21951
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
21808
21952
|
const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
|
|
21809
21953
|
const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
21954
|
+
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
21955
|
+
async function assertWordpressTargetAllowed(baseUrl) {
|
|
21956
|
+
const check = await resolveWebhookTarget(baseUrl, { allowLoopback });
|
|
21957
|
+
if (!check.ok) {
|
|
21958
|
+
throw validationError(`WordPress baseUrl rejected: ${check.message}`);
|
|
21959
|
+
}
|
|
21960
|
+
const { address, family } = check.target;
|
|
21961
|
+
return new UndiciAgent({
|
|
21962
|
+
connect: {
|
|
21963
|
+
lookup: (_hostname, _options, cb) => {
|
|
21964
|
+
cb(null, address, family === 6 ? 6 : 4);
|
|
21965
|
+
}
|
|
21966
|
+
}
|
|
21967
|
+
});
|
|
21968
|
+
}
|
|
21969
|
+
async function withPinnedWordpressDispatcher(baseUrl, fn) {
|
|
21970
|
+
const dispatcher = await assertWordpressTargetAllowed(baseUrl);
|
|
21971
|
+
try {
|
|
21972
|
+
return await fn(dispatcher);
|
|
21973
|
+
} finally {
|
|
21974
|
+
await dispatcher.close().catch(() => {
|
|
21975
|
+
});
|
|
21976
|
+
}
|
|
21977
|
+
}
|
|
21810
21978
|
const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
|
|
21811
21979
|
const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
|
|
21812
21980
|
const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
|
|
@@ -21905,23 +22073,26 @@ async function trafficRoutes(app, opts) {
|
|
|
21905
22073
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
21906
22074
|
}
|
|
21907
22075
|
const { baseUrl, username, applicationPassword, displayName } = parsed.data;
|
|
21908
|
-
|
|
21909
|
-
|
|
21910
|
-
|
|
21911
|
-
|
|
21912
|
-
|
|
21913
|
-
|
|
21914
|
-
|
|
21915
|
-
|
|
21916
|
-
|
|
21917
|
-
|
|
21918
|
-
|
|
21919
|
-
|
|
21920
|
-
|
|
22076
|
+
await withPinnedWordpressDispatcher(baseUrl, async (dispatcher) => {
|
|
22077
|
+
try {
|
|
22078
|
+
await pullWordpressEvents({
|
|
22079
|
+
baseUrl,
|
|
22080
|
+
username,
|
|
22081
|
+
applicationPassword,
|
|
22082
|
+
pageSize: 1,
|
|
22083
|
+
maxPages: 1,
|
|
22084
|
+
dispatcher
|
|
22085
|
+
});
|
|
22086
|
+
} catch (e) {
|
|
22087
|
+
if (e instanceof WordpressTrafficApiError) {
|
|
22088
|
+
throw providerError(
|
|
22089
|
+
`WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
22090
|
+
);
|
|
22091
|
+
}
|
|
22092
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
22093
|
+
throw providerError(`WordPress traffic probe failed: ${msg}`);
|
|
21921
22094
|
}
|
|
21922
|
-
|
|
21923
|
-
throw providerError(`WordPress traffic probe failed: ${msg}`);
|
|
21924
|
-
}
|
|
22095
|
+
});
|
|
21925
22096
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
21926
22097
|
const existing = credentialStore.getConnection(project.name);
|
|
21927
22098
|
credentialStore.upsertConnection({
|
|
@@ -22171,6 +22342,14 @@ async function trafficRoutes(app, opts) {
|
|
|
22171
22342
|
windowStart = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt) : windowEnd;
|
|
22172
22343
|
const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
|
|
22173
22344
|
const wpMaxPages = opts.defaultWordpressMaxPages ?? DEFAULT_WP_MAX_PAGES;
|
|
22345
|
+
let pinnedDispatcher;
|
|
22346
|
+
try {
|
|
22347
|
+
pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
|
|
22348
|
+
} catch (e) {
|
|
22349
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
22350
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
22351
|
+
throw e;
|
|
22352
|
+
}
|
|
22174
22353
|
const collected = [];
|
|
22175
22354
|
let cursor = sourceRow.lastCursor ?? void 0;
|
|
22176
22355
|
try {
|
|
@@ -22181,7 +22360,8 @@ async function trafficRoutes(app, opts) {
|
|
|
22181
22360
|
applicationPassword: credential.applicationPassword,
|
|
22182
22361
|
cursor,
|
|
22183
22362
|
pageSize: wpPageSize,
|
|
22184
|
-
maxPages: 1
|
|
22363
|
+
maxPages: 1,
|
|
22364
|
+
dispatcher: pinnedDispatcher
|
|
22185
22365
|
});
|
|
22186
22366
|
collected.push(...pageResult.events);
|
|
22187
22367
|
const previousCursor = cursor;
|
|
@@ -22195,6 +22375,9 @@ async function trafficRoutes(app, opts) {
|
|
|
22195
22375
|
const msg = e instanceof Error ? e.message : String(e);
|
|
22196
22376
|
markFailed(msg, "PROVIDER_PULL");
|
|
22197
22377
|
throw providerError(`WordPress pull failed: ${msg}`);
|
|
22378
|
+
} finally {
|
|
22379
|
+
await pinnedDispatcher.close().catch(() => {
|
|
22380
|
+
});
|
|
22198
22381
|
}
|
|
22199
22382
|
} else {
|
|
22200
22383
|
auditAction = "traffic.vercel.synced";
|
|
@@ -22221,28 +22404,24 @@ async function trafficRoutes(app, opts) {
|
|
|
22221
22404
|
windowStart = new Date(
|
|
22222
22405
|
Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
|
|
22223
22406
|
);
|
|
22224
|
-
let page;
|
|
22225
22407
|
try {
|
|
22226
|
-
|
|
22408
|
+
const drained = await drainVercelTrafficEvents({
|
|
22409
|
+
pull: pullVercelEvents,
|
|
22227
22410
|
token: credential.token,
|
|
22228
22411
|
projectId: vercelProjectId,
|
|
22229
22412
|
teamId: vercelTeamId,
|
|
22230
22413
|
environment: vercelEnvironment,
|
|
22231
22414
|
startDate: windowStart.getTime(),
|
|
22232
22415
|
endDate: windowEnd.getTime(),
|
|
22233
|
-
|
|
22416
|
+
pagesPerSubWindow: vercelMaxPages,
|
|
22417
|
+
maxSubWindows: VERCEL_MAX_SUB_WINDOWS
|
|
22234
22418
|
});
|
|
22419
|
+
allEvents = drained.events;
|
|
22235
22420
|
} catch (e) {
|
|
22236
22421
|
const msg = e instanceof Error ? e.message : String(e);
|
|
22237
22422
|
markFailed(msg, "PROVIDER_PULL");
|
|
22238
22423
|
throw providerError(`Vercel pull failed: ${msg}`);
|
|
22239
22424
|
}
|
|
22240
|
-
if (page.hasMore) {
|
|
22241
|
-
const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
|
|
22242
|
-
markFailed(msg, "PROVIDER_PULL");
|
|
22243
|
-
throw providerError(`Vercel pull failed: ${msg}`);
|
|
22244
|
-
}
|
|
22245
|
-
allEvents = page.events;
|
|
22246
22425
|
}
|
|
22247
22426
|
let crawlerBucketRows = 0;
|
|
22248
22427
|
let aiUserFetchBucketRows = 0;
|
|
@@ -22529,33 +22708,42 @@ async function trafficRoutes(app, opts) {
|
|
|
22529
22708
|
`No WordPress credential found for project "${project.name}". Run "canonry traffic connect wordpress" first.`
|
|
22530
22709
|
);
|
|
22531
22710
|
}
|
|
22711
|
+
await (await assertWordpressTargetAllowed(credential.baseUrl)).close().catch(() => {
|
|
22712
|
+
});
|
|
22532
22713
|
const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
|
|
22533
22714
|
pullErrorPrefix = "WordPress pull failed";
|
|
22534
22715
|
pullForBackfill = async () => {
|
|
22535
|
-
const
|
|
22536
|
-
|
|
22537
|
-
|
|
22538
|
-
|
|
22539
|
-
|
|
22540
|
-
|
|
22541
|
-
|
|
22542
|
-
|
|
22543
|
-
|
|
22544
|
-
|
|
22545
|
-
|
|
22546
|
-
|
|
22547
|
-
|
|
22548
|
-
|
|
22549
|
-
|
|
22550
|
-
|
|
22716
|
+
const pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
|
|
22717
|
+
try {
|
|
22718
|
+
const collected = [];
|
|
22719
|
+
const windowStartIso = windowStart.toISOString();
|
|
22720
|
+
const windowEndIso = windowEnd.toISOString();
|
|
22721
|
+
let cursor = void 0;
|
|
22722
|
+
for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
|
|
22723
|
+
const pageResult = await pullWordpressEvents({
|
|
22724
|
+
baseUrl: credential.baseUrl,
|
|
22725
|
+
username: credential.username,
|
|
22726
|
+
applicationPassword: credential.applicationPassword,
|
|
22727
|
+
cursor,
|
|
22728
|
+
pageSize: wpPageSize,
|
|
22729
|
+
// Each call fetches a single page; the for-loop drives
|
|
22730
|
+
// continuation. Matches the WP sync path's pattern.
|
|
22731
|
+
maxPages: 1,
|
|
22732
|
+
since: windowStartIso,
|
|
22733
|
+
until: windowEndIso,
|
|
22734
|
+
dispatcher: pinnedDispatcher
|
|
22735
|
+
});
|
|
22736
|
+
collected.push(...pageResult.events);
|
|
22737
|
+
const previousCursor = cursor;
|
|
22738
|
+
cursor = pageResult.nextCursor;
|
|
22739
|
+
if (!pageResult.hasMore) break;
|
|
22740
|
+
if (!cursor || cursor === previousCursor) break;
|
|
22741
|
+
}
|
|
22742
|
+
return collected;
|
|
22743
|
+
} finally {
|
|
22744
|
+
await pinnedDispatcher.close().catch(() => {
|
|
22551
22745
|
});
|
|
22552
|
-
collected.push(...pageResult.events);
|
|
22553
|
-
const previousCursor = cursor;
|
|
22554
|
-
cursor = pageResult.nextCursor;
|
|
22555
|
-
if (!pageResult.hasMore) break;
|
|
22556
|
-
if (!cursor || cursor === previousCursor) break;
|
|
22557
22746
|
}
|
|
22558
|
-
return collected;
|
|
22559
22747
|
};
|
|
22560
22748
|
} else {
|
|
22561
22749
|
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
@@ -22574,21 +22762,18 @@ async function trafficRoutes(app, opts) {
|
|
|
22574
22762
|
const vercelEnvironment = config.environment ?? credential.environment;
|
|
22575
22763
|
pullErrorPrefix = "Vercel pull failed";
|
|
22576
22764
|
pullForBackfill = async () => {
|
|
22577
|
-
const
|
|
22765
|
+
const drained = await drainVercelTrafficEvents({
|
|
22766
|
+
pull: pullVercelEvents,
|
|
22578
22767
|
token: credential.token,
|
|
22579
22768
|
projectId: vercelProjectId,
|
|
22580
22769
|
teamId: vercelTeamId,
|
|
22581
22770
|
environment: vercelEnvironment,
|
|
22582
22771
|
startDate: windowStart.getTime(),
|
|
22583
22772
|
endDate: windowEnd.getTime(),
|
|
22584
|
-
|
|
22773
|
+
pagesPerSubWindow: vercelMaxPages,
|
|
22774
|
+
maxSubWindows: VERCEL_MAX_SUB_WINDOWS
|
|
22585
22775
|
});
|
|
22586
|
-
|
|
22587
|
-
throw new Error(
|
|
22588
|
-
`backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
|
|
22589
|
-
);
|
|
22590
|
-
}
|
|
22591
|
-
return page.events;
|
|
22776
|
+
return drained.events;
|
|
22592
22777
|
};
|
|
22593
22778
|
}
|
|
22594
22779
|
const startedAt = windowEnd.toISOString();
|
|
@@ -23899,6 +24084,76 @@ var TRAFFIC_SOURCE_CHECKS = [
|
|
|
23899
24084
|
scopesCheck2
|
|
23900
24085
|
];
|
|
23901
24086
|
|
|
24087
|
+
// ../api-routes/src/doctor/checks/wordpress-publish.ts
|
|
24088
|
+
var WORDPRESS_PUBLISH_CHECKS = [
|
|
24089
|
+
{
|
|
24090
|
+
id: "wordpress.publish.connection",
|
|
24091
|
+
category: CheckCategories.auth,
|
|
24092
|
+
scope: CheckScopes.project,
|
|
24093
|
+
title: "WordPress publishing connection",
|
|
24094
|
+
run: async (ctx) => {
|
|
24095
|
+
if (!ctx.project) {
|
|
24096
|
+
return {
|
|
24097
|
+
status: CheckStatuses.skipped,
|
|
24098
|
+
code: "wordpress.publish.no-project",
|
|
24099
|
+
summary: "Project context required.",
|
|
24100
|
+
remediation: null
|
|
24101
|
+
};
|
|
24102
|
+
}
|
|
24103
|
+
const store = ctx.wordpressConnectionStore;
|
|
24104
|
+
if (!store) {
|
|
24105
|
+
return {
|
|
24106
|
+
status: CheckStatuses.skipped,
|
|
24107
|
+
code: "wordpress.publish.store-unavailable",
|
|
24108
|
+
summary: "WordPress connection store is not configured for this deployment.",
|
|
24109
|
+
remediation: null
|
|
24110
|
+
};
|
|
24111
|
+
}
|
|
24112
|
+
const connection = store.getConnection(ctx.project.name);
|
|
24113
|
+
if (!connection) {
|
|
24114
|
+
return {
|
|
24115
|
+
status: CheckStatuses.skipped,
|
|
24116
|
+
code: "wordpress.publish.not-configured",
|
|
24117
|
+
summary: `No WordPress publishing connection configured for ${ctx.project.name}.`,
|
|
24118
|
+
remediation: `If this project publishes to WordPress, run \`canonry wordpress connect ${ctx.project.name} --url <url> --user <user>\`.`
|
|
24119
|
+
};
|
|
24120
|
+
}
|
|
24121
|
+
try {
|
|
24122
|
+
const status = await verifyWordpressConnection(connection);
|
|
24123
|
+
return {
|
|
24124
|
+
status: CheckStatuses.ok,
|
|
24125
|
+
code: "wordpress.publish.connected",
|
|
24126
|
+
summary: `WordPress publishing connection verified; wp/v2 REST API reachable at ${status.url}.`,
|
|
24127
|
+
remediation: null,
|
|
24128
|
+
details: {
|
|
24129
|
+
url: status.url,
|
|
24130
|
+
wordpressVersion: status.version,
|
|
24131
|
+
pageCount: status.pageCount
|
|
24132
|
+
}
|
|
24133
|
+
};
|
|
24134
|
+
} catch (err) {
|
|
24135
|
+
if (err instanceof WordpressApiError && err.code === "AUTH_INVALID") {
|
|
24136
|
+
return {
|
|
24137
|
+
status: CheckStatuses.fail,
|
|
24138
|
+
code: "wordpress.publish.unauthorized",
|
|
24139
|
+
summary: "WordPress rejected the stored application password.",
|
|
24140
|
+
remediation: `Regenerate the Application Password in wp-admin (Users \u2192 Profile \u2192 Application Passwords), then reconnect with \`canonry wordpress connect ${ctx.project.name} --url <url> --user <user>\`.`,
|
|
24141
|
+
details: { error: err.message }
|
|
24142
|
+
};
|
|
24143
|
+
}
|
|
24144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24145
|
+
return {
|
|
24146
|
+
status: CheckStatuses.fail,
|
|
24147
|
+
code: "wordpress.publish.verification-failed",
|
|
24148
|
+
summary: "WordPress publishing connection could not be verified.",
|
|
24149
|
+
remediation: "Confirm the site URL is correct and the WordPress REST API is reachable.",
|
|
24150
|
+
details: { error: message }
|
|
24151
|
+
};
|
|
24152
|
+
}
|
|
24153
|
+
}
|
|
24154
|
+
}
|
|
24155
|
+
];
|
|
24156
|
+
|
|
23902
24157
|
// ../api-routes/src/doctor/registry.ts
|
|
23903
24158
|
var ALL_CHECKS = [
|
|
23904
24159
|
// Runtime-state checks run first so file-system gone errors surface
|
|
@@ -23906,6 +24161,7 @@ var ALL_CHECKS = [
|
|
|
23906
24161
|
...RUNTIME_STATE_CHECKS,
|
|
23907
24162
|
...GOOGLE_AUTH_CHECKS,
|
|
23908
24163
|
...BING_AUTH_CHECKS,
|
|
24164
|
+
...WORDPRESS_PUBLISH_CHECKS,
|
|
23909
24165
|
...GA_AUTH_CHECKS,
|
|
23910
24166
|
...PROVIDERS_CHECKS,
|
|
23911
24167
|
...TRAFFIC_SOURCE_CHECKS,
|
|
@@ -23990,6 +24246,7 @@ async function doctorRoutes(app, opts) {
|
|
|
23990
24246
|
project: null,
|
|
23991
24247
|
googleConnectionStore: opts.googleConnectionStore,
|
|
23992
24248
|
bingConnectionStore: opts.bingConnectionStore,
|
|
24249
|
+
wordpressConnectionStore: opts.wordpressConnectionStore,
|
|
23993
24250
|
ga4CredentialStore: opts.ga4CredentialStore,
|
|
23994
24251
|
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
23995
24252
|
redirectUri,
|
|
@@ -24012,6 +24269,7 @@ async function doctorRoutes(app, opts) {
|
|
|
24012
24269
|
},
|
|
24013
24270
|
googleConnectionStore: opts.googleConnectionStore,
|
|
24014
24271
|
bingConnectionStore: opts.bingConnectionStore,
|
|
24272
|
+
wordpressConnectionStore: opts.wordpressConnectionStore,
|
|
24015
24273
|
ga4CredentialStore: opts.ga4CredentialStore,
|
|
24016
24274
|
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
24017
24275
|
redirectUri,
|
|
@@ -24623,7 +24881,8 @@ async function apiRoutes(app, opts) {
|
|
|
24623
24881
|
pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
|
|
24624
24882
|
vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
|
|
24625
24883
|
pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
|
|
24626
|
-
onTrafficSynced: opts.onTrafficSynced
|
|
24884
|
+
onTrafficSynced: opts.onTrafficSynced,
|
|
24885
|
+
allowLoopbackWebhooks: opts.allowLoopbackWebhooks
|
|
24627
24886
|
});
|
|
24628
24887
|
await api.register(backlinksRoutes, {
|
|
24629
24888
|
getBacklinksStatus: opts.getBacklinksStatus,
|
|
@@ -24640,6 +24899,7 @@ async function apiRoutes(app, opts) {
|
|
|
24640
24899
|
await api.register(doctorRoutes, {
|
|
24641
24900
|
googleConnectionStore: opts.googleConnectionStore,
|
|
24642
24901
|
bingConnectionStore: opts.bingConnectionStore,
|
|
24902
|
+
wordpressConnectionStore: opts.wordpressConnectionStore,
|
|
24643
24903
|
ga4CredentialStore: opts.ga4CredentialStore,
|
|
24644
24904
|
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
24645
24905
|
publicUrl: opts.publicUrl,
|
|
@@ -27172,7 +27432,8 @@ function upsertGoogleConnection(config, connection) {
|
|
|
27172
27432
|
sitemapUrl: connection.sitemapUrl ?? null,
|
|
27173
27433
|
refreshToken: connection.refreshToken ?? null,
|
|
27174
27434
|
tokenExpiresAt: connection.tokenExpiresAt ?? null,
|
|
27175
|
-
scopes: connection.scopes ?? []
|
|
27435
|
+
scopes: connection.scopes ?? [],
|
|
27436
|
+
createdByProjectId: connection.createdByProjectId ?? null
|
|
27176
27437
|
};
|
|
27177
27438
|
if (index === -1) {
|
|
27178
27439
|
connections.push(normalized);
|
|
@@ -29802,7 +30063,7 @@ function readStoredGroundingSources(rawResponse) {
|
|
|
29802
30063
|
return result;
|
|
29803
30064
|
}
|
|
29804
30065
|
async function backfillInsightsCommand(project, opts) {
|
|
29805
|
-
const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-
|
|
30066
|
+
const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-NKAEHHJ5.js");
|
|
29806
30067
|
const config = loadConfig();
|
|
29807
30068
|
const db = createClient(config.database);
|
|
29808
30069
|
migrate(db);
|
|
@@ -32957,6 +33218,48 @@ function summarizeProviderConfig(provider, config) {
|
|
|
32957
33218
|
function hashApiKey(key) {
|
|
32958
33219
|
return crypto35.createHash("sha256").update(key).digest("hex");
|
|
32959
33220
|
}
|
|
33221
|
+
var DASHBOARD_SCRYPT_KEYLEN = 64;
|
|
33222
|
+
var DASHBOARD_SCRYPT_COST = 1 << 15;
|
|
33223
|
+
var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
|
33224
|
+
function hashDashboardPassword(password) {
|
|
33225
|
+
const salt = crypto35.randomBytes(16);
|
|
33226
|
+
const derived = crypto35.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
|
|
33227
|
+
N: DASHBOARD_SCRYPT_COST,
|
|
33228
|
+
maxmem: DASHBOARD_SCRYPT_MAXMEM
|
|
33229
|
+
});
|
|
33230
|
+
return `scrypt$1$${salt.toString("base64")}$${derived.toString("base64")}`;
|
|
33231
|
+
}
|
|
33232
|
+
function verifyDashboardPassword(password, storedHash) {
|
|
33233
|
+
if (storedHash.startsWith("scrypt$1$")) {
|
|
33234
|
+
const parts = storedHash.split("$");
|
|
33235
|
+
if (parts.length !== 4) return { ok: false, needsRehash: false };
|
|
33236
|
+
const saltB64 = parts[2];
|
|
33237
|
+
const hashB64 = parts[3];
|
|
33238
|
+
if (!saltB64 || !hashB64) return { ok: false, needsRehash: false };
|
|
33239
|
+
let salt;
|
|
33240
|
+
let expected;
|
|
33241
|
+
try {
|
|
33242
|
+
salt = Buffer.from(saltB64, "base64");
|
|
33243
|
+
expected = Buffer.from(hashB64, "base64");
|
|
33244
|
+
} catch {
|
|
33245
|
+
return { ok: false, needsRehash: false };
|
|
33246
|
+
}
|
|
33247
|
+
const derived = crypto35.scryptSync(password, salt, expected.length, {
|
|
33248
|
+
N: DASHBOARD_SCRYPT_COST,
|
|
33249
|
+
maxmem: DASHBOARD_SCRYPT_MAXMEM
|
|
33250
|
+
});
|
|
33251
|
+
if (derived.length !== expected.length) return { ok: false, needsRehash: false };
|
|
33252
|
+
return { ok: crypto35.timingSafeEqual(derived, expected), needsRehash: false };
|
|
33253
|
+
}
|
|
33254
|
+
if (/^[a-f0-9]{64}$/i.test(storedHash)) {
|
|
33255
|
+
const candidate = Buffer.from(hashApiKey(password), "hex");
|
|
33256
|
+
const expected = Buffer.from(storedHash, "hex");
|
|
33257
|
+
if (candidate.length !== expected.length) return { ok: false, needsRehash: false };
|
|
33258
|
+
const ok = crypto35.timingSafeEqual(candidate, expected);
|
|
33259
|
+
return { ok, needsRehash: ok };
|
|
33260
|
+
}
|
|
33261
|
+
return { ok: false, needsRehash: false };
|
|
33262
|
+
}
|
|
32960
33263
|
function parseCookies2(header) {
|
|
32961
33264
|
if (!header) return {};
|
|
32962
33265
|
return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
|
|
@@ -33185,13 +33488,14 @@ async function createServer(opts) {
|
|
|
33185
33488
|
if (!opts.config.bing) opts.config.bing = {};
|
|
33186
33489
|
if (!opts.config.bing.connections) opts.config.bing.connections = [];
|
|
33187
33490
|
const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
|
|
33491
|
+
const normalized = { ...connection, createdByProjectId: connection.createdByProjectId ?? null };
|
|
33188
33492
|
if (idx >= 0) {
|
|
33189
|
-
opts.config.bing.connections[idx] =
|
|
33493
|
+
opts.config.bing.connections[idx] = normalized;
|
|
33190
33494
|
} else {
|
|
33191
|
-
opts.config.bing.connections.push(
|
|
33495
|
+
opts.config.bing.connections.push(normalized);
|
|
33192
33496
|
}
|
|
33193
33497
|
saveConfigPatch(opts.config);
|
|
33194
|
-
return
|
|
33498
|
+
return normalized;
|
|
33195
33499
|
},
|
|
33196
33500
|
updateConnection: (domain, patch) => {
|
|
33197
33501
|
const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
|
|
@@ -33399,7 +33703,7 @@ async function createServer(opts) {
|
|
|
33399
33703
|
const err = validationError("Password must be at least 8 characters");
|
|
33400
33704
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
33401
33705
|
}
|
|
33402
|
-
opts.config.dashboardPasswordHash =
|
|
33706
|
+
opts.config.dashboardPasswordHash = hashDashboardPassword(password);
|
|
33403
33707
|
saveConfigPatch(opts.config);
|
|
33404
33708
|
if (!createPasswordSession(reply)) {
|
|
33405
33709
|
const err = authInvalid();
|
|
@@ -33415,9 +33719,14 @@ async function createServer(opts) {
|
|
|
33415
33719
|
const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
|
|
33416
33720
|
return reply.status(err2.statusCode).send(err2.toJSON());
|
|
33417
33721
|
}
|
|
33418
|
-
|
|
33722
|
+
const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash);
|
|
33723
|
+
if (!verification.ok) {
|
|
33419
33724
|
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
|
|
33420
33725
|
}
|
|
33726
|
+
if (verification.needsRehash) {
|
|
33727
|
+
opts.config.dashboardPasswordHash = hashDashboardPassword(password);
|
|
33728
|
+
saveConfigPatch(opts.config);
|
|
33729
|
+
}
|
|
33421
33730
|
if (!createPasswordSession(reply)) {
|
|
33422
33731
|
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
|
|
33423
33732
|
}
|