@ainyc/canonry 4.54.0 → 4.55.3
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 +9 -4
- package/assets/assets/{BacklinksPage-BXFT4pLI.js → BacklinksPage-buvZ4ZOd.js} +1 -1
- package/assets/assets/{ProjectPage-DAtd9Vay.js → ProjectPage-D0UqSqe7.js} +4 -4
- package/assets/assets/{RunRow-38dDceGl.js → RunRow-D-DTu1PA.js} +1 -1
- package/assets/assets/{RunsPage-AJnFLtaE.js → RunsPage-CrBpgwkO.js} +1 -1
- package/assets/assets/{SettingsPage-FT9ZAvFH.js → SettingsPage-Bgsi9tZ2.js} +1 -1
- package/assets/assets/TrafficPage-DAHXrzqz.js +1 -0
- package/assets/assets/TrafficSourceDetailPage-DCcDN3VD.js +1 -0
- package/assets/assets/extract-error-message-BGhWiJPr.js +1 -0
- package/assets/assets/{index-DLPKqyhx.js → index-CbDkoDBH.js} +62 -62
- package/assets/assets/{index-Bm3JQsW0.css → index-dxdJhCQO.css} +1 -1
- package/assets/assets/{server-traffic-GqiQYm6x.js → server-traffic-3xxyOEIX.js} +1 -1
- package/assets/assets/{trash-2-BwPzJ8NI.js → trash-2-dppRdHYI.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-CRO6Q25G.js → chunk-5EAGNVCJ.js} +423 -250
- package/dist/{chunk-J7MX3YOH.js → chunk-UOQ62KDD.js} +8 -3
- package/dist/{chunk-JHAHNKSN.js → chunk-XB6Y63NI.js} +260 -5
- package/dist/{chunk-VZPDBHBW.js → chunk-XHU35P3S.js} +367 -365
- package/dist/cli.js +14 -12
- package/dist/index.d.ts +13 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-OCREQUCQ.js → intelligence-service-4PT22FED.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +15 -14
- package/assets/assets/TrafficPage-B4A3oO8M.js +0 -1
- package/assets/assets/TrafficSourceDetailPage-8NYU1TA6.js +0 -1
- package/assets/assets/arrow-left-DgI0X1Q1.js +0 -1
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
loadConfig,
|
|
7
7
|
loadConfigRaw,
|
|
8
8
|
saveConfigPatch
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-UOQ62KDD.js";
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
12
12
|
IntelligenceService,
|
|
@@ -45,14 +45,17 @@ import {
|
|
|
45
45
|
categorizeQueryByIntent,
|
|
46
46
|
ccReleaseSyncs,
|
|
47
47
|
competitors,
|
|
48
|
+
computeCompetitorOverlap,
|
|
48
49
|
contentTargetDismissals,
|
|
49
50
|
crawlerEventsHourly,
|
|
50
51
|
createClient,
|
|
51
52
|
createLogger,
|
|
53
|
+
determineCitationState,
|
|
52
54
|
discoveryProbes,
|
|
53
55
|
discoverySessions,
|
|
54
56
|
dropLegacyCredentialColumns,
|
|
55
57
|
extractLegacyCredentials,
|
|
58
|
+
extractRecommendedCompetitors,
|
|
56
59
|
filterTrackedSnapshots,
|
|
57
60
|
gaAiReferrals,
|
|
58
61
|
gaSocialReferrals,
|
|
@@ -84,7 +87,7 @@ import {
|
|
|
84
87
|
smoothedRunDelta,
|
|
85
88
|
trafficSources,
|
|
86
89
|
usageCounters
|
|
87
|
-
} from "./chunk-
|
|
90
|
+
} from "./chunk-XB6Y63NI.js";
|
|
88
91
|
import {
|
|
89
92
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
90
93
|
AGENT_PROVIDER_IDS,
|
|
@@ -144,7 +147,6 @@ import {
|
|
|
144
147
|
bingSitesResponseDtoSchema,
|
|
145
148
|
bingStatusDtoSchema,
|
|
146
149
|
bingUrlInspectionDtoSchema,
|
|
147
|
-
brandKeyFromText,
|
|
148
150
|
brandLabelFromDomain,
|
|
149
151
|
buildRunErrorFromMessages,
|
|
150
152
|
categorizeSource,
|
|
@@ -185,6 +187,7 @@ import {
|
|
|
185
187
|
emptyCitationVisibility,
|
|
186
188
|
extractAnswerMentions,
|
|
187
189
|
findDuplicateLocationLabels,
|
|
190
|
+
forbidden,
|
|
188
191
|
formatDate,
|
|
189
192
|
formatDateRange,
|
|
190
193
|
formatDeltaCopy,
|
|
@@ -285,7 +288,7 @@ import {
|
|
|
285
288
|
wordpressSchemaDeployResultDtoSchema,
|
|
286
289
|
wordpressSchemaStatusResultDtoSchema,
|
|
287
290
|
wordpressStatusDtoSchema
|
|
288
|
-
} from "./chunk-
|
|
291
|
+
} from "./chunk-XHU35P3S.js";
|
|
289
292
|
|
|
290
293
|
// src/telemetry.ts
|
|
291
294
|
import crypto from "crypto";
|
|
@@ -586,6 +589,12 @@ import fs8 from "fs";
|
|
|
586
589
|
// ../api-routes/src/auth.ts
|
|
587
590
|
import crypto2 from "crypto";
|
|
588
591
|
import { eq } from "drizzle-orm";
|
|
592
|
+
function requireScope(request, scope) {
|
|
593
|
+
const key = request.apiKey;
|
|
594
|
+
if (!key) return;
|
|
595
|
+
if (key.scopes.includes("*") || key.scopes.includes(scope)) return;
|
|
596
|
+
throw forbidden(`This action requires the "${scope}" scope on your API key.`);
|
|
597
|
+
}
|
|
589
598
|
function hashKey(key) {
|
|
590
599
|
return crypto2.createHash("sha256").update(key).digest("hex");
|
|
591
600
|
}
|
|
@@ -645,6 +654,8 @@ async function authPlugin(app, opts = {}) {
|
|
|
645
654
|
throw authRequired();
|
|
646
655
|
}
|
|
647
656
|
app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
|
|
657
|
+
const scopes = Array.isArray(key.scopes) ? key.scopes : [];
|
|
658
|
+
request.apiKey = { id: key.id, name: key.name, scopes };
|
|
648
659
|
});
|
|
649
660
|
}
|
|
650
661
|
|
|
@@ -1648,14 +1659,16 @@ async function runRoutes(app, opts) {
|
|
|
1648
1659
|
const project = resolveProject(app.db, request.params.name);
|
|
1649
1660
|
const parsedLimit = parseInt(request.query.limit ?? "", 10);
|
|
1650
1661
|
const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
|
|
1651
|
-
const
|
|
1662
|
+
const kind = parseListKind(request.query.kind);
|
|
1663
|
+
const where = kind ? and2(eq7(runs.projectId, project.id), eq7(runs.kind, kind)) : eq7(runs.projectId, project.id);
|
|
1664
|
+
const rows = limit == null ? app.db.select().from(runs).where(where).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(where).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
|
|
1652
1665
|
return reply.send(rows.map(formatRun));
|
|
1653
1666
|
});
|
|
1654
1667
|
app.get("/projects/:name/runs/latest", async (request, reply) => {
|
|
1655
1668
|
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();
|
|
1669
|
+
const countRow = app.db.select({ count: sql4`count(*)` }).from(runs).where(and2(eq7(runs.projectId, project.id), notProbeRun())).get();
|
|
1657
1670
|
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();
|
|
1671
|
+
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
1672
|
if (!latestRun) {
|
|
1660
1673
|
return reply.send({ totalRuns: 0, run: null });
|
|
1661
1674
|
}
|
|
@@ -3816,6 +3829,14 @@ var COLORS = {
|
|
|
3816
3829
|
function escapeHtml(value) {
|
|
3817
3830
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3818
3831
|
}
|
|
3832
|
+
function safeHref(value) {
|
|
3833
|
+
const trimmed = (value ?? "").trim();
|
|
3834
|
+
if (!trimmed) return "#";
|
|
3835
|
+
if (trimmed.startsWith("/")) return escapeHtml(trimmed);
|
|
3836
|
+
if (/^https?:\/\//i.test(trimmed)) return escapeHtml(trimmed);
|
|
3837
|
+
if (/^mailto:/i.test(trimmed)) return escapeHtml(trimmed);
|
|
3838
|
+
return "#";
|
|
3839
|
+
}
|
|
3819
3840
|
function summarizeQueryParams(params) {
|
|
3820
3841
|
const keys = Array.from(params.keys());
|
|
3821
3842
|
const total = keys.length;
|
|
@@ -5075,7 +5096,7 @@ function renderCompetitorLandscape(report) {
|
|
|
5075
5096
|
const mentionCount = mention?.mentionCount ?? 0;
|
|
5076
5097
|
const mentionTotal = mention?.totalCount ?? mentionLandscape.totalAnswerSnapshots;
|
|
5077
5098
|
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="${
|
|
5099
|
+
<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
5100
|
</details>` : "";
|
|
5080
5101
|
return `<tr>
|
|
5081
5102
|
<td>${escapeHtml(c.domain)}</td>
|
|
@@ -5490,7 +5511,7 @@ function renderServerActivity(report, audience) {
|
|
|
5490
5511
|
<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
5512
|
<tbody>${clientOperatorRows}</tbody>
|
|
5492
5513
|
</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
|
|
5514
|
+
<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
5515
|
</div>` : ""}`
|
|
5495
5516
|
);
|
|
5496
5517
|
}
|
|
@@ -5556,7 +5577,7 @@ function renderServerActivity(report, audience) {
|
|
|
5556
5577
|
</div>
|
|
5557
5578
|
${trendChart}
|
|
5558
5579
|
${operatorRows ? `<div class="chart-card"><h3>Per AI operator</h3>
|
|
5559
|
-
<p class="meta">Verified means
|
|
5580
|
+
<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
5581
|
<table class="report-table">
|
|
5561
5582
|
<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
5583
|
<tbody>${operatorRows}</tbody>
|
|
@@ -5707,8 +5728,8 @@ function renderOpportunities(report) {
|
|
|
5707
5728
|
</article>`).join("")}
|
|
5708
5729
|
</div>`;
|
|
5709
5730
|
const rows = opps.slice(0, 10).map((o) => {
|
|
5710
|
-
const ourPage = o.ourBestPage ? `<a href="${
|
|
5711
|
-
const winning = o.winningCompetitor ? `<a href="${
|
|
5731
|
+
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>';
|
|
5732
|
+
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
5733
|
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
5734
|
return `<tr>
|
|
5714
5735
|
<td>${escapeHtml(o.query)}</td>
|
|
@@ -6026,10 +6047,12 @@ function renderReportHtml(report, opts = {}) {
|
|
|
6026
6047
|
renderRecommendedNextSteps(report)
|
|
6027
6048
|
].join("\n");
|
|
6028
6049
|
const json = escapeJsonForScript(JSON.stringify(report));
|
|
6050
|
+
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
6051
|
return `<!DOCTYPE html>
|
|
6030
6052
|
<html lang="en">
|
|
6031
6053
|
<head>
|
|
6032
6054
|
<meta charset="utf-8" />
|
|
6055
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
|
6033
6056
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6034
6057
|
<title>${escapeHtml(title)}</title>
|
|
6035
6058
|
<style>${STYLE}</style>
|
|
@@ -9099,7 +9122,7 @@ var routeCatalog = [
|
|
|
9099
9122
|
path: "/api/v1/projects/{name}/runs",
|
|
9100
9123
|
summary: "List project runs",
|
|
9101
9124
|
tags: ["runs"],
|
|
9102
|
-
parameters: [nameParameter, limitQueryParameter],
|
|
9125
|
+
parameters: [nameParameter, limitQueryParameter, runsListKindQueryParameter],
|
|
9103
9126
|
responses: {
|
|
9104
9127
|
200: jsonArrayResponse("Runs returned.", "RunDto")
|
|
9105
9128
|
}
|
|
@@ -11392,7 +11415,7 @@ var routeCatalog = [
|
|
|
11392
11415
|
method: "post",
|
|
11393
11416
|
path: "/api/v1/projects/{name}/traffic/connect/vercel",
|
|
11394
11417
|
summary: "Connect a Vercel traffic source",
|
|
11395
|
-
description: "Probes Vercel's internal `request-logs` endpoint with the supplied
|
|
11418
|
+
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
11419
|
tags: ["traffic"],
|
|
11397
11420
|
parameters: [nameParameter],
|
|
11398
11421
|
requestBody: {
|
|
@@ -11404,8 +11427,8 @@ var routeCatalog = [
|
|
|
11404
11427
|
required: ["projectId", "teamId", "token"],
|
|
11405
11428
|
properties: {
|
|
11406
11429
|
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
|
|
11430
|
+
teamId: { ...stringSchema, description: "Vercel team or account id: the org that owns the project (`orgId` in `.vercel/project.json`)." },
|
|
11431
|
+
token: { ...stringSchema, description: "Vercel personal access token. Stored in `~/.canonry/config.yaml`, never the DB or response." },
|
|
11409
11432
|
environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
|
|
11410
11433
|
displayName: stringSchema
|
|
11411
11434
|
}
|
|
@@ -11454,7 +11477,7 @@ var routeCatalog = [
|
|
|
11454
11477
|
method: "post",
|
|
11455
11478
|
path: "/api/v1/projects/{name}/traffic/sources/{id}/backfill",
|
|
11456
11479
|
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`.',
|
|
11480
|
+
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
11481
|
tags: ["traffic"],
|
|
11459
11482
|
parameters: [
|
|
11460
11483
|
nameParameter,
|
|
@@ -11885,6 +11908,7 @@ function buildOperationId(method, path16) {
|
|
|
11885
11908
|
}
|
|
11886
11909
|
|
|
11887
11910
|
// ../api-routes/src/settings.ts
|
|
11911
|
+
var SETTINGS_WRITE_SCOPE = "settings.write";
|
|
11888
11912
|
async function settingsRoutes(app, opts) {
|
|
11889
11913
|
app.get("/settings", async () => ({
|
|
11890
11914
|
providers: opts.providerSummary ?? [],
|
|
@@ -11892,6 +11916,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11892
11916
|
bing: opts.bing ?? { configured: false }
|
|
11893
11917
|
}));
|
|
11894
11918
|
app.put("/settings/providers/:name", async (request) => {
|
|
11919
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11895
11920
|
const { apiKey, baseUrl, model, quota } = request.body ?? {};
|
|
11896
11921
|
const name = request.params.name;
|
|
11897
11922
|
const adapters = opts.providerAdapters ?? [];
|
|
@@ -11950,6 +11975,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11950
11975
|
return result;
|
|
11951
11976
|
});
|
|
11952
11977
|
app.put("/settings/google", async (request) => {
|
|
11978
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11953
11979
|
const { clientId, clientSecret } = request.body ?? {};
|
|
11954
11980
|
if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
|
|
11955
11981
|
throw validationError("clientId and clientSecret are required");
|
|
@@ -11964,6 +11990,7 @@ async function settingsRoutes(app, opts) {
|
|
|
11964
11990
|
return result;
|
|
11965
11991
|
});
|
|
11966
11992
|
app.put("/settings/bing", async (request) => {
|
|
11993
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
11967
11994
|
const { apiKey } = request.body ?? {};
|
|
11968
11995
|
if (!apiKey || typeof apiKey !== "string") {
|
|
11969
11996
|
throw validationError("apiKey is required");
|
|
@@ -13482,7 +13509,14 @@ async function googleRoutes(app, opts) {
|
|
|
13482
13509
|
}
|
|
13483
13510
|
const scopes = type === "gsc" ? [GSC_SCOPE, INDEXING_SCOPE] : [GA4_SCOPE];
|
|
13484
13511
|
const stateEncoded = buildSignedState(
|
|
13485
|
-
{
|
|
13512
|
+
{
|
|
13513
|
+
projectId: project.id,
|
|
13514
|
+
projectName: project.name,
|
|
13515
|
+
domain: project.canonicalDomain,
|
|
13516
|
+
type,
|
|
13517
|
+
propertyId,
|
|
13518
|
+
redirectUri
|
|
13519
|
+
},
|
|
13486
13520
|
stateSecret
|
|
13487
13521
|
);
|
|
13488
13522
|
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
@@ -13529,7 +13563,19 @@ async function googleRoutes(app, opts) {
|
|
|
13529
13563
|
if (!stateData) {
|
|
13530
13564
|
return reply.status(400).send("Invalid or tampered state parameter");
|
|
13531
13565
|
}
|
|
13532
|
-
const { domain, type, propertyId, redirectUri } = stateData;
|
|
13566
|
+
const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
|
|
13567
|
+
if (!projectId) {
|
|
13568
|
+
return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
|
|
13569
|
+
}
|
|
13570
|
+
const project = app.db.select().from(projects).where(eq19(projects.id, projectId)).get();
|
|
13571
|
+
if (!project) {
|
|
13572
|
+
return reply.status(400).send("Project no longer exists. Restart the connect flow.");
|
|
13573
|
+
}
|
|
13574
|
+
if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
|
|
13575
|
+
return reply.status(400).send(
|
|
13576
|
+
`Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
|
|
13577
|
+
);
|
|
13578
|
+
}
|
|
13533
13579
|
let tokens;
|
|
13534
13580
|
try {
|
|
13535
13581
|
tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
@@ -13550,6 +13596,11 @@ async function googleRoutes(app, opts) {
|
|
|
13550
13596
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
13551
13597
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
13552
13598
|
const existing = store.getConnection(domain, type);
|
|
13599
|
+
if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
|
|
13600
|
+
return reply.status(403).send(
|
|
13601
|
+
`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 ?? "")}".`
|
|
13602
|
+
);
|
|
13603
|
+
}
|
|
13553
13604
|
store.upsertConnection({
|
|
13554
13605
|
domain,
|
|
13555
13606
|
connectionType: type,
|
|
@@ -13558,6 +13609,9 @@ async function googleRoutes(app, opts) {
|
|
|
13558
13609
|
refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
|
|
13559
13610
|
tokenExpiresAt: expiresAt,
|
|
13560
13611
|
scopes: tokens.scope?.split(" ") ?? [],
|
|
13612
|
+
// Stamp ownership on first write; subsequent same-project re-connects
|
|
13613
|
+
// preserve it.
|
|
13614
|
+
createdByProjectId: existing?.createdByProjectId ?? projectId,
|
|
13561
13615
|
createdAt: existing?.createdAt ?? now,
|
|
13562
13616
|
updatedAt: now
|
|
13563
13617
|
});
|
|
@@ -13586,16 +13640,26 @@ async function googleRoutes(app, opts) {
|
|
|
13586
13640
|
app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
|
|
13587
13641
|
const store = requireConnectionStore();
|
|
13588
13642
|
const project = resolveProject(app.db, request.params.name);
|
|
13589
|
-
const
|
|
13643
|
+
const type = request.params.type;
|
|
13644
|
+
const existing = store.getConnection(project.canonicalDomain, type);
|
|
13645
|
+
if (!existing) {
|
|
13646
|
+
throw notFound("Google connection", type);
|
|
13647
|
+
}
|
|
13648
|
+
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
13649
|
+
throw validationError(
|
|
13650
|
+
`This Google ${type.toUpperCase()} connection is owned by a different project. Disconnect from the owning project instead.`
|
|
13651
|
+
);
|
|
13652
|
+
}
|
|
13653
|
+
const deleted = store.deleteConnection(project.canonicalDomain, type);
|
|
13590
13654
|
if (!deleted) {
|
|
13591
|
-
throw notFound("Google connection",
|
|
13655
|
+
throw notFound("Google connection", type);
|
|
13592
13656
|
}
|
|
13593
13657
|
writeAuditLog(app.db, {
|
|
13594
13658
|
projectId: project.id,
|
|
13595
13659
|
actor: "api",
|
|
13596
13660
|
action: "google.disconnected",
|
|
13597
13661
|
entityType: "google_connection",
|
|
13598
|
-
entityId:
|
|
13662
|
+
entityId: type
|
|
13599
13663
|
});
|
|
13600
13664
|
return reply.status(204).send();
|
|
13601
13665
|
});
|
|
@@ -14342,10 +14406,16 @@ async function bingRoutes(app, opts) {
|
|
|
14342
14406
|
}
|
|
14343
14407
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
14344
14408
|
const existing = store.getConnection(project.canonicalDomain);
|
|
14409
|
+
if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
14410
|
+
throw validationError(
|
|
14411
|
+
`This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
|
|
14412
|
+
);
|
|
14413
|
+
}
|
|
14345
14414
|
store.upsertConnection({
|
|
14346
14415
|
domain: project.canonicalDomain,
|
|
14347
14416
|
apiKey,
|
|
14348
14417
|
siteUrl: existing?.siteUrl ?? null,
|
|
14418
|
+
createdByProjectId: existing?.createdByProjectId ?? project.id,
|
|
14349
14419
|
createdAt: existing?.createdAt ?? now,
|
|
14350
14420
|
updatedAt: now
|
|
14351
14421
|
});
|
|
@@ -14366,6 +14436,15 @@ async function bingRoutes(app, opts) {
|
|
|
14366
14436
|
app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
|
|
14367
14437
|
const store = requireConnectionStore();
|
|
14368
14438
|
const project = resolveProject(app.db, request.params.name);
|
|
14439
|
+
const existing = store.getConnection(project.canonicalDomain);
|
|
14440
|
+
if (!existing) {
|
|
14441
|
+
throw notFound("Bing connection", project.canonicalDomain);
|
|
14442
|
+
}
|
|
14443
|
+
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
14444
|
+
throw validationError(
|
|
14445
|
+
`This Bing connection is owned by a different project. Disconnect from the owning project instead.`
|
|
14446
|
+
);
|
|
14447
|
+
}
|
|
14369
14448
|
const deleted = store.deleteConnection(project.canonicalDomain);
|
|
14370
14449
|
if (!deleted) {
|
|
14371
14450
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
@@ -14775,6 +14854,7 @@ async function cdpRoutes(app, opts) {
|
|
|
14775
14854
|
return reply.type("image/png").send(stream);
|
|
14776
14855
|
});
|
|
14777
14856
|
app.put("/settings/cdp", async (request, reply) => {
|
|
14857
|
+
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
14778
14858
|
if (!opts.onCdpConfigure) {
|
|
14779
14859
|
const err = notImplemented("CDP configuration not supported in this deployment");
|
|
14780
14860
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
@@ -18085,6 +18165,7 @@ async function backlinksRoutes(app, opts) {
|
|
|
18085
18165
|
|
|
18086
18166
|
// ../api-routes/src/traffic.ts
|
|
18087
18167
|
import crypto21 from "crypto";
|
|
18168
|
+
import { Agent as UndiciAgent } from "undici";
|
|
18088
18169
|
import { and as and19, desc as desc13, eq as eq24, gte as gte3, lte as lte2, sql as sql10 } from "drizzle-orm";
|
|
18089
18170
|
|
|
18090
18171
|
// ../integration-cloud-run/src/auth.ts
|
|
@@ -21364,6 +21445,7 @@ async function listWordpressTrafficEvents(options) {
|
|
|
21364
21445
|
let skippedEntryCount = 0;
|
|
21365
21446
|
let hasMore = false;
|
|
21366
21447
|
const events = [];
|
|
21448
|
+
const dispatcher = options.dispatcher;
|
|
21367
21449
|
for (let page = 0; page < maxPages; page += 1) {
|
|
21368
21450
|
const url = new URL(endpoint);
|
|
21369
21451
|
url.searchParams.set("limit", String(pageSize));
|
|
@@ -21377,7 +21459,7 @@ async function listWordpressTrafficEvents(options) {
|
|
|
21377
21459
|
url.searchParams.set("until", options.until);
|
|
21378
21460
|
}
|
|
21379
21461
|
url.searchParams.set("_cb", randomUUID());
|
|
21380
|
-
const
|
|
21462
|
+
const fetchInit = {
|
|
21381
21463
|
method: "GET",
|
|
21382
21464
|
headers: {
|
|
21383
21465
|
Authorization: authHeader,
|
|
@@ -21385,7 +21467,11 @@ async function listWordpressTrafficEvents(options) {
|
|
|
21385
21467
|
"Cache-Control": "no-cache"
|
|
21386
21468
|
},
|
|
21387
21469
|
signal: AbortSignal.timeout(timeoutMs)
|
|
21388
|
-
}
|
|
21470
|
+
};
|
|
21471
|
+
if (dispatcher !== void 0) {
|
|
21472
|
+
fetchInit.dispatcher = dispatcher;
|
|
21473
|
+
}
|
|
21474
|
+
const response = await fetch(url, fetchInit);
|
|
21389
21475
|
if (!response.ok) {
|
|
21390
21476
|
const body2 = await readErrorBody2(response);
|
|
21391
21477
|
throw new WordpressTrafficApiError(
|
|
@@ -21599,6 +21685,132 @@ async function listVercelTrafficEvents(options) {
|
|
|
21599
21685
|
};
|
|
21600
21686
|
}
|
|
21601
21687
|
|
|
21688
|
+
// ../integration-vercel/src/drain.ts
|
|
21689
|
+
var MIN_SUB_WINDOW_MS = 6e4;
|
|
21690
|
+
var FLOOR_SLICE_MAX_PAGES = 1e3;
|
|
21691
|
+
var RETENTION_BOUNDARY_TOLERANCE_MS = 60 * 6e4;
|
|
21692
|
+
function toMs(value) {
|
|
21693
|
+
return typeof value === "number" ? value : value.getTime();
|
|
21694
|
+
}
|
|
21695
|
+
function isRetentionError(error) {
|
|
21696
|
+
return error instanceof VercelLogsApiError && error.status === 400 && (error.body ?? "").includes("ExceedsBillingLimitError");
|
|
21697
|
+
}
|
|
21698
|
+
async function isServable(options, windowStartMs, windowEndMs) {
|
|
21699
|
+
try {
|
|
21700
|
+
await options.pull({
|
|
21701
|
+
token: options.token,
|
|
21702
|
+
projectId: options.projectId,
|
|
21703
|
+
teamId: options.teamId,
|
|
21704
|
+
environment: options.environment,
|
|
21705
|
+
startDate: windowStartMs,
|
|
21706
|
+
endDate: windowEndMs,
|
|
21707
|
+
maxPages: 1
|
|
21708
|
+
});
|
|
21709
|
+
return true;
|
|
21710
|
+
} catch (error) {
|
|
21711
|
+
if (isRetentionError(error)) return false;
|
|
21712
|
+
throw error;
|
|
21713
|
+
}
|
|
21714
|
+
}
|
|
21715
|
+
async function resolveRetainedStart(options, unservableStartMs, endMs) {
|
|
21716
|
+
const tailStartMs = Math.max(unservableStartMs, endMs - MIN_SUB_WINDOW_MS);
|
|
21717
|
+
if (!await isServable(options, tailStartMs, endMs)) {
|
|
21718
|
+
return endMs;
|
|
21719
|
+
}
|
|
21720
|
+
let lo = unservableStartMs;
|
|
21721
|
+
let hi = tailStartMs;
|
|
21722
|
+
while (hi - lo > RETENTION_BOUNDARY_TOLERANCE_MS) {
|
|
21723
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
21724
|
+
if (await isServable(options, mid, endMs)) {
|
|
21725
|
+
hi = mid;
|
|
21726
|
+
} else {
|
|
21727
|
+
lo = mid;
|
|
21728
|
+
}
|
|
21729
|
+
}
|
|
21730
|
+
return hi;
|
|
21731
|
+
}
|
|
21732
|
+
async function drainVercelTrafficEvents(options) {
|
|
21733
|
+
const startMs = toMs(options.startDate);
|
|
21734
|
+
const endMs = toMs(options.endDate);
|
|
21735
|
+
const events = [];
|
|
21736
|
+
const seenEventIds = /* @__PURE__ */ new Set();
|
|
21737
|
+
if (endMs <= startMs) {
|
|
21738
|
+
return { events, subWindowCount: 0, effectiveStartMs: startMs, retentionClamped: false };
|
|
21739
|
+
}
|
|
21740
|
+
let cursorMs = startMs;
|
|
21741
|
+
let spanMs = endMs - startMs;
|
|
21742
|
+
let subWindowCount = 0;
|
|
21743
|
+
let effectiveStartMs = startMs;
|
|
21744
|
+
let retentionClamped = false;
|
|
21745
|
+
let retentionResolved = false;
|
|
21746
|
+
while (cursorMs < endMs) {
|
|
21747
|
+
if (subWindowCount >= options.maxSubWindows) {
|
|
21748
|
+
throw new Error(
|
|
21749
|
+
`Vercel window not drained within ${options.maxSubWindows} sub-windows \u2014 narrow the time range`
|
|
21750
|
+
);
|
|
21751
|
+
}
|
|
21752
|
+
const subEndMs = Math.min(cursorMs + spanMs, endMs);
|
|
21753
|
+
let page;
|
|
21754
|
+
try {
|
|
21755
|
+
page = await options.pull({
|
|
21756
|
+
token: options.token,
|
|
21757
|
+
projectId: options.projectId,
|
|
21758
|
+
teamId: options.teamId,
|
|
21759
|
+
environment: options.environment,
|
|
21760
|
+
startDate: cursorMs,
|
|
21761
|
+
endDate: subEndMs,
|
|
21762
|
+
maxPages: options.pagesPerSubWindow
|
|
21763
|
+
});
|
|
21764
|
+
} catch (error) {
|
|
21765
|
+
if (isRetentionError(error) && !retentionResolved) {
|
|
21766
|
+
retentionResolved = true;
|
|
21767
|
+
const retainedStartMs = await resolveRetainedStart(options, cursorMs, endMs);
|
|
21768
|
+
retentionClamped = retainedStartMs > cursorMs;
|
|
21769
|
+
cursorMs = retainedStartMs;
|
|
21770
|
+
effectiveStartMs = retainedStartMs;
|
|
21771
|
+
spanMs = Math.max(endMs - cursorMs, MIN_SUB_WINDOW_MS);
|
|
21772
|
+
continue;
|
|
21773
|
+
}
|
|
21774
|
+
throw error;
|
|
21775
|
+
}
|
|
21776
|
+
subWindowCount += 1;
|
|
21777
|
+
if (page.hasMore) {
|
|
21778
|
+
const subSpanMs = subEndMs - cursorMs;
|
|
21779
|
+
if (subSpanMs > MIN_SUB_WINDOW_MS) {
|
|
21780
|
+
spanMs = Math.max(Math.floor(subSpanMs / 2), MIN_SUB_WINDOW_MS);
|
|
21781
|
+
continue;
|
|
21782
|
+
}
|
|
21783
|
+
page = await options.pull({
|
|
21784
|
+
token: options.token,
|
|
21785
|
+
projectId: options.projectId,
|
|
21786
|
+
teamId: options.teamId,
|
|
21787
|
+
environment: options.environment,
|
|
21788
|
+
startDate: cursorMs,
|
|
21789
|
+
endDate: subEndMs,
|
|
21790
|
+
maxPages: FLOOR_SLICE_MAX_PAGES
|
|
21791
|
+
});
|
|
21792
|
+
subWindowCount += 1;
|
|
21793
|
+
if (page.hasMore) {
|
|
21794
|
+
throw new Error(
|
|
21795
|
+
`Vercel ${MIN_SUB_WINDOW_MS / 6e4}-minute slice holds more than ${FLOOR_SLICE_MAX_PAGES} pages and cannot be drained further`
|
|
21796
|
+
);
|
|
21797
|
+
}
|
|
21798
|
+
}
|
|
21799
|
+
for (const event of page.events) {
|
|
21800
|
+
if (!seenEventIds.has(event.eventId)) {
|
|
21801
|
+
seenEventIds.add(event.eventId);
|
|
21802
|
+
events.push(event);
|
|
21803
|
+
}
|
|
21804
|
+
}
|
|
21805
|
+
cursorMs = subEndMs;
|
|
21806
|
+
const remainingMs = endMs - cursorMs;
|
|
21807
|
+
if (remainingMs > 0) {
|
|
21808
|
+
spanMs = Math.min(spanMs * 2, remainingMs);
|
|
21809
|
+
}
|
|
21810
|
+
}
|
|
21811
|
+
return { events, subWindowCount, effectiveStartMs, retentionClamped };
|
|
21812
|
+
}
|
|
21813
|
+
|
|
21602
21814
|
// ../api-routes/src/traffic.ts
|
|
21603
21815
|
var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
|
|
21604
21816
|
var DEFAULT_PAGE_SIZE3 = 1e3;
|
|
@@ -21607,6 +21819,7 @@ var DEFAULT_SAMPLE_LIMIT2 = 100;
|
|
|
21607
21819
|
var DEFAULT_WP_PAGE_SIZE = 500;
|
|
21608
21820
|
var DEFAULT_WP_MAX_PAGES = 20;
|
|
21609
21821
|
var DEFAULT_VERCEL_MAX_PAGES = 50;
|
|
21822
|
+
var VERCEL_MAX_SUB_WINDOWS = 5e3;
|
|
21610
21823
|
var MAX_TRACKED_EVENT_IDS = 1e3;
|
|
21611
21824
|
var DEFAULT_BACKFILL_DAYS = 30;
|
|
21612
21825
|
var MAX_BACKFILL_DAYS = 90;
|
|
@@ -21812,6 +22025,34 @@ async function trafficRoutes(app, opts) {
|
|
|
21812
22025
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
21813
22026
|
const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
|
|
21814
22027
|
const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
22028
|
+
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
22029
|
+
async function assertWordpressTargetAllowed(baseUrl) {
|
|
22030
|
+
const check = await resolveWebhookTarget(baseUrl, { allowLoopback });
|
|
22031
|
+
if (!check.ok) {
|
|
22032
|
+
throw validationError(`WordPress baseUrl rejected: ${check.message}`);
|
|
22033
|
+
}
|
|
22034
|
+
const { address, family } = check.target;
|
|
22035
|
+
return new UndiciAgent({
|
|
22036
|
+
connect: {
|
|
22037
|
+
lookup: (_hostname, options, cb) => {
|
|
22038
|
+
if (options?.all) {
|
|
22039
|
+
cb(null, [{ address, family: family === 6 ? 6 : 4 }]);
|
|
22040
|
+
} else {
|
|
22041
|
+
cb(null, address, family === 6 ? 6 : 4);
|
|
22042
|
+
}
|
|
22043
|
+
}
|
|
22044
|
+
}
|
|
22045
|
+
});
|
|
22046
|
+
}
|
|
22047
|
+
async function withPinnedWordpressDispatcher(baseUrl, fn) {
|
|
22048
|
+
const dispatcher = await assertWordpressTargetAllowed(baseUrl);
|
|
22049
|
+
try {
|
|
22050
|
+
return await fn(dispatcher);
|
|
22051
|
+
} finally {
|
|
22052
|
+
await dispatcher.close().catch(() => {
|
|
22053
|
+
});
|
|
22054
|
+
}
|
|
22055
|
+
}
|
|
21815
22056
|
const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
|
|
21816
22057
|
const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
|
|
21817
22058
|
const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
|
|
@@ -21910,23 +22151,26 @@ async function trafficRoutes(app, opts) {
|
|
|
21910
22151
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
21911
22152
|
}
|
|
21912
22153
|
const { baseUrl, username, applicationPassword, displayName } = parsed.data;
|
|
21913
|
-
|
|
21914
|
-
|
|
21915
|
-
|
|
21916
|
-
|
|
21917
|
-
|
|
21918
|
-
|
|
21919
|
-
|
|
21920
|
-
|
|
21921
|
-
|
|
21922
|
-
|
|
21923
|
-
|
|
21924
|
-
|
|
21925
|
-
|
|
22154
|
+
await withPinnedWordpressDispatcher(baseUrl, async (dispatcher) => {
|
|
22155
|
+
try {
|
|
22156
|
+
await pullWordpressEvents({
|
|
22157
|
+
baseUrl,
|
|
22158
|
+
username,
|
|
22159
|
+
applicationPassword,
|
|
22160
|
+
pageSize: 1,
|
|
22161
|
+
maxPages: 1,
|
|
22162
|
+
dispatcher
|
|
22163
|
+
});
|
|
22164
|
+
} catch (e) {
|
|
22165
|
+
if (e instanceof WordpressTrafficApiError) {
|
|
22166
|
+
throw providerError(
|
|
22167
|
+
`WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
22168
|
+
);
|
|
22169
|
+
}
|
|
22170
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
22171
|
+
throw providerError(`WordPress traffic probe failed: ${msg}`);
|
|
21926
22172
|
}
|
|
21927
|
-
|
|
21928
|
-
throw providerError(`WordPress traffic probe failed: ${msg}`);
|
|
21929
|
-
}
|
|
22173
|
+
});
|
|
21930
22174
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
21931
22175
|
const existing = credentialStore.getConnection(project.name);
|
|
21932
22176
|
credentialStore.upsertConnection({
|
|
@@ -22176,6 +22420,14 @@ async function trafficRoutes(app, opts) {
|
|
|
22176
22420
|
windowStart = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt) : windowEnd;
|
|
22177
22421
|
const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
|
|
22178
22422
|
const wpMaxPages = opts.defaultWordpressMaxPages ?? DEFAULT_WP_MAX_PAGES;
|
|
22423
|
+
let pinnedDispatcher;
|
|
22424
|
+
try {
|
|
22425
|
+
pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
|
|
22426
|
+
} catch (e) {
|
|
22427
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
22428
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
22429
|
+
throw e;
|
|
22430
|
+
}
|
|
22179
22431
|
const collected = [];
|
|
22180
22432
|
let cursor = sourceRow.lastCursor ?? void 0;
|
|
22181
22433
|
try {
|
|
@@ -22186,7 +22438,8 @@ async function trafficRoutes(app, opts) {
|
|
|
22186
22438
|
applicationPassword: credential.applicationPassword,
|
|
22187
22439
|
cursor,
|
|
22188
22440
|
pageSize: wpPageSize,
|
|
22189
|
-
maxPages: 1
|
|
22441
|
+
maxPages: 1,
|
|
22442
|
+
dispatcher: pinnedDispatcher
|
|
22190
22443
|
});
|
|
22191
22444
|
collected.push(...pageResult.events);
|
|
22192
22445
|
const previousCursor = cursor;
|
|
@@ -22200,6 +22453,9 @@ async function trafficRoutes(app, opts) {
|
|
|
22200
22453
|
const msg = e instanceof Error ? e.message : String(e);
|
|
22201
22454
|
markFailed(msg, "PROVIDER_PULL");
|
|
22202
22455
|
throw providerError(`WordPress pull failed: ${msg}`);
|
|
22456
|
+
} finally {
|
|
22457
|
+
await pinnedDispatcher.close().catch(() => {
|
|
22458
|
+
});
|
|
22203
22459
|
}
|
|
22204
22460
|
} else {
|
|
22205
22461
|
auditAction = "traffic.vercel.synced";
|
|
@@ -22226,28 +22482,34 @@ async function trafficRoutes(app, opts) {
|
|
|
22226
22482
|
windowStart = new Date(
|
|
22227
22483
|
Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
|
|
22228
22484
|
);
|
|
22229
|
-
let page;
|
|
22230
22485
|
try {
|
|
22231
|
-
|
|
22486
|
+
const drained = await drainVercelTrafficEvents({
|
|
22487
|
+
pull: pullVercelEvents,
|
|
22232
22488
|
token: credential.token,
|
|
22233
22489
|
projectId: vercelProjectId,
|
|
22234
22490
|
teamId: vercelTeamId,
|
|
22235
22491
|
environment: vercelEnvironment,
|
|
22236
22492
|
startDate: windowStart.getTime(),
|
|
22237
22493
|
endDate: windowEnd.getTime(),
|
|
22238
|
-
|
|
22494
|
+
pagesPerSubWindow: vercelMaxPages,
|
|
22495
|
+
maxSubWindows: VERCEL_MAX_SUB_WINDOWS
|
|
22239
22496
|
});
|
|
22497
|
+
if (drained.retentionClamped) {
|
|
22498
|
+
app.log.warn(
|
|
22499
|
+
{
|
|
22500
|
+
sourceId: sourceRow.id,
|
|
22501
|
+
requestedStart: windowStart.toISOString(),
|
|
22502
|
+
servedStart: new Date(drained.effectiveStartMs).toISOString()
|
|
22503
|
+
},
|
|
22504
|
+
"Vercel request-logs retention clamp: sync window predated plan retention; ingested only the portion Vercel still serves"
|
|
22505
|
+
);
|
|
22506
|
+
}
|
|
22507
|
+
allEvents = drained.events;
|
|
22240
22508
|
} catch (e) {
|
|
22241
22509
|
const msg = e instanceof Error ? e.message : String(e);
|
|
22242
22510
|
markFailed(msg, "PROVIDER_PULL");
|
|
22243
22511
|
throw providerError(`Vercel pull failed: ${msg}`);
|
|
22244
22512
|
}
|
|
22245
|
-
if (page.hasMore) {
|
|
22246
|
-
const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
|
|
22247
|
-
markFailed(msg, "PROVIDER_PULL");
|
|
22248
|
-
throw providerError(`Vercel pull failed: ${msg}`);
|
|
22249
|
-
}
|
|
22250
|
-
allEvents = page.events;
|
|
22251
22513
|
}
|
|
22252
22514
|
let crawlerBucketRows = 0;
|
|
22253
22515
|
let aiUserFetchBucketRows = 0;
|
|
@@ -22534,33 +22796,42 @@ async function trafficRoutes(app, opts) {
|
|
|
22534
22796
|
`No WordPress credential found for project "${project.name}". Run "canonry traffic connect wordpress" first.`
|
|
22535
22797
|
);
|
|
22536
22798
|
}
|
|
22799
|
+
await (await assertWordpressTargetAllowed(credential.baseUrl)).close().catch(() => {
|
|
22800
|
+
});
|
|
22537
22801
|
const wpPageSize = opts.defaultWordpressPageSize ?? DEFAULT_WP_PAGE_SIZE;
|
|
22538
22802
|
pullErrorPrefix = "WordPress pull failed";
|
|
22539
22803
|
pullForBackfill = async () => {
|
|
22540
|
-
const
|
|
22541
|
-
|
|
22542
|
-
|
|
22543
|
-
|
|
22544
|
-
|
|
22545
|
-
|
|
22546
|
-
|
|
22547
|
-
|
|
22548
|
-
|
|
22549
|
-
|
|
22550
|
-
|
|
22551
|
-
|
|
22552
|
-
|
|
22553
|
-
|
|
22554
|
-
|
|
22555
|
-
|
|
22804
|
+
const pinnedDispatcher = await assertWordpressTargetAllowed(credential.baseUrl);
|
|
22805
|
+
try {
|
|
22806
|
+
const collected = [];
|
|
22807
|
+
const windowStartIso = windowStart.toISOString();
|
|
22808
|
+
const windowEndIso = windowEnd.toISOString();
|
|
22809
|
+
let cursor = void 0;
|
|
22810
|
+
for (let page = 0; page < BACKFILL_MAX_PAGES; page += 1) {
|
|
22811
|
+
const pageResult = await pullWordpressEvents({
|
|
22812
|
+
baseUrl: credential.baseUrl,
|
|
22813
|
+
username: credential.username,
|
|
22814
|
+
applicationPassword: credential.applicationPassword,
|
|
22815
|
+
cursor,
|
|
22816
|
+
pageSize: wpPageSize,
|
|
22817
|
+
// Each call fetches a single page; the for-loop drives
|
|
22818
|
+
// continuation. Matches the WP sync path's pattern.
|
|
22819
|
+
maxPages: 1,
|
|
22820
|
+
since: windowStartIso,
|
|
22821
|
+
until: windowEndIso,
|
|
22822
|
+
dispatcher: pinnedDispatcher
|
|
22823
|
+
});
|
|
22824
|
+
collected.push(...pageResult.events);
|
|
22825
|
+
const previousCursor = cursor;
|
|
22826
|
+
cursor = pageResult.nextCursor;
|
|
22827
|
+
if (!pageResult.hasMore) break;
|
|
22828
|
+
if (!cursor || cursor === previousCursor) break;
|
|
22829
|
+
}
|
|
22830
|
+
return collected;
|
|
22831
|
+
} finally {
|
|
22832
|
+
await pinnedDispatcher.close().catch(() => {
|
|
22556
22833
|
});
|
|
22557
|
-
collected.push(...pageResult.events);
|
|
22558
|
-
const previousCursor = cursor;
|
|
22559
|
-
cursor = pageResult.nextCursor;
|
|
22560
|
-
if (!pageResult.hasMore) break;
|
|
22561
|
-
if (!cursor || cursor === previousCursor) break;
|
|
22562
22834
|
}
|
|
22563
|
-
return collected;
|
|
22564
22835
|
};
|
|
22565
22836
|
} else {
|
|
22566
22837
|
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
@@ -22579,21 +22850,33 @@ async function trafficRoutes(app, opts) {
|
|
|
22579
22850
|
const vercelEnvironment = config.environment ?? credential.environment;
|
|
22580
22851
|
pullErrorPrefix = "Vercel pull failed";
|
|
22581
22852
|
pullForBackfill = async () => {
|
|
22582
|
-
const
|
|
22853
|
+
const drained = await drainVercelTrafficEvents({
|
|
22854
|
+
pull: pullVercelEvents,
|
|
22583
22855
|
token: credential.token,
|
|
22584
22856
|
projectId: vercelProjectId,
|
|
22585
22857
|
teamId: vercelTeamId,
|
|
22586
22858
|
environment: vercelEnvironment,
|
|
22587
22859
|
startDate: windowStart.getTime(),
|
|
22588
22860
|
endDate: windowEnd.getTime(),
|
|
22589
|
-
|
|
22861
|
+
pagesPerSubWindow: vercelMaxPages,
|
|
22862
|
+
maxSubWindows: VERCEL_MAX_SUB_WINDOWS
|
|
22590
22863
|
});
|
|
22591
|
-
if (
|
|
22592
|
-
|
|
22593
|
-
|
|
22864
|
+
if (drained.retentionClamped) {
|
|
22865
|
+
const servedDays = Math.round(
|
|
22866
|
+
(windowEnd.getTime() - drained.effectiveStartMs) / 864e5
|
|
22867
|
+
);
|
|
22868
|
+
app.log.warn(
|
|
22869
|
+
{
|
|
22870
|
+
sourceId: sourceRow.id,
|
|
22871
|
+
requestedDays,
|
|
22872
|
+
servedDays,
|
|
22873
|
+
requestedStart: windowStart.toISOString(),
|
|
22874
|
+
servedStart: new Date(drained.effectiveStartMs).toISOString()
|
|
22875
|
+
},
|
|
22876
|
+
"Vercel request-logs retention clamp: backfill window predates plan retention; ingested only the portion Vercel still serves"
|
|
22594
22877
|
);
|
|
22595
22878
|
}
|
|
22596
|
-
return
|
|
22879
|
+
return drained.events;
|
|
22597
22880
|
};
|
|
22598
22881
|
}
|
|
22599
22882
|
const startedAt = windowEnd.toISOString();
|
|
@@ -24701,7 +24984,8 @@ async function apiRoutes(app, opts) {
|
|
|
24701
24984
|
pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
|
|
24702
24985
|
vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
|
|
24703
24986
|
pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
|
|
24704
|
-
onTrafficSynced: opts.onTrafficSynced
|
|
24987
|
+
onTrafficSynced: opts.onTrafficSynced,
|
|
24988
|
+
allowLoopbackWebhooks: opts.allowLoopbackWebhooks
|
|
24705
24989
|
});
|
|
24706
24990
|
await api.register(backlinksRoutes, {
|
|
24707
24991
|
getBacklinksStatus: opts.getBacklinksStatus,
|
|
@@ -27251,7 +27535,8 @@ function upsertGoogleConnection(config, connection) {
|
|
|
27251
27535
|
sitemapUrl: connection.sitemapUrl ?? null,
|
|
27252
27536
|
refreshToken: connection.refreshToken ?? null,
|
|
27253
27537
|
tokenExpiresAt: connection.tokenExpiresAt ?? null,
|
|
27254
|
-
scopes: connection.scopes ?? []
|
|
27538
|
+
scopes: connection.scopes ?? [],
|
|
27539
|
+
createdByProjectId: connection.createdByProjectId ?? null
|
|
27255
27540
|
};
|
|
27256
27541
|
if (index === -1) {
|
|
27257
27542
|
connections.push(normalized);
|
|
@@ -27512,166 +27797,6 @@ function buildRunCompletedProps(input) {
|
|
|
27512
27797
|
return props;
|
|
27513
27798
|
}
|
|
27514
27799
|
|
|
27515
|
-
// src/citation-utils.ts
|
|
27516
|
-
function domainMatches(domain, canonicalDomain) {
|
|
27517
|
-
const normalized = normalizeProjectDomain(canonicalDomain);
|
|
27518
|
-
const d = normalizeProjectDomain(domain);
|
|
27519
|
-
return d === normalized || d.endsWith(`.${normalized}`);
|
|
27520
|
-
}
|
|
27521
|
-
function determineCitationState(normalized, domains) {
|
|
27522
|
-
for (const canonicalDomain of domains) {
|
|
27523
|
-
const bareDomain = normalizeProjectDomain(canonicalDomain);
|
|
27524
|
-
if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
|
|
27525
|
-
return "cited";
|
|
27526
|
-
}
|
|
27527
|
-
const lowerDomain = bareDomain.toLowerCase();
|
|
27528
|
-
for (const source of normalized.groundingSources) {
|
|
27529
|
-
try {
|
|
27530
|
-
const uri = source.uri.toLowerCase();
|
|
27531
|
-
if (lowerDomain.includes(".") && uri.includes(lowerDomain)) {
|
|
27532
|
-
return "cited";
|
|
27533
|
-
}
|
|
27534
|
-
} catch {
|
|
27535
|
-
}
|
|
27536
|
-
if (source.title) {
|
|
27537
|
-
const titleLower = source.title.toLowerCase().replace(/^www\./, "");
|
|
27538
|
-
if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
|
|
27539
|
-
return "cited";
|
|
27540
|
-
}
|
|
27541
|
-
}
|
|
27542
|
-
}
|
|
27543
|
-
}
|
|
27544
|
-
return "not-cited";
|
|
27545
|
-
}
|
|
27546
|
-
function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
27547
|
-
const overlapSet = /* @__PURE__ */ new Set();
|
|
27548
|
-
for (const d of normalized.citedDomains) {
|
|
27549
|
-
for (const cd of competitorDomains) {
|
|
27550
|
-
if (domainMatches(d, cd)) {
|
|
27551
|
-
overlapSet.add(cd);
|
|
27552
|
-
}
|
|
27553
|
-
}
|
|
27554
|
-
}
|
|
27555
|
-
for (const source of normalized.groundingSources) {
|
|
27556
|
-
const uri = source.uri.toLowerCase();
|
|
27557
|
-
for (const cd of competitorDomains) {
|
|
27558
|
-
if (uri.includes(cd.toLowerCase())) {
|
|
27559
|
-
overlapSet.add(cd);
|
|
27560
|
-
}
|
|
27561
|
-
}
|
|
27562
|
-
}
|
|
27563
|
-
if (normalized.answerText) {
|
|
27564
|
-
const lowerAnswer = normalized.answerText.toLowerCase();
|
|
27565
|
-
for (const cd of competitorDomains) {
|
|
27566
|
-
if (lowerAnswer.includes(cd.toLowerCase())) {
|
|
27567
|
-
overlapSet.add(cd);
|
|
27568
|
-
}
|
|
27569
|
-
const brand = brandLabelFromDomain(cd);
|
|
27570
|
-
if (brand.length >= 4 && new RegExp(`\\b${escapeRegExp5(brand)}\\b`, "i").test(lowerAnswer)) {
|
|
27571
|
-
overlapSet.add(cd);
|
|
27572
|
-
}
|
|
27573
|
-
}
|
|
27574
|
-
}
|
|
27575
|
-
return [...overlapSet];
|
|
27576
|
-
}
|
|
27577
|
-
function escapeRegExp5(value) {
|
|
27578
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27579
|
-
}
|
|
27580
|
-
function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains, ownBrandNames = []) {
|
|
27581
|
-
if (!answerText || answerText.length < 20) return [];
|
|
27582
|
-
const ownBrandKeys = new Set(
|
|
27583
|
-
ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
|
|
27584
|
-
);
|
|
27585
|
-
for (const name of ownBrandNames) {
|
|
27586
|
-
const key = brandKeyFromText(name);
|
|
27587
|
-
if (key.length >= 4) ownBrandKeys.add(key);
|
|
27588
|
-
}
|
|
27589
|
-
const knownCompetitorKeys = new Set(
|
|
27590
|
-
[...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
|
|
27591
|
-
);
|
|
27592
|
-
if (knownCompetitorKeys.size === 0) return [];
|
|
27593
|
-
const candidatePatterns = [
|
|
27594
|
-
/^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013-]/gm,
|
|
27595
|
-
/\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\*\*/g,
|
|
27596
|
-
/^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
|
|
27597
|
-
/\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\]\(https?:\/\/[^\s)]+\)/g
|
|
27598
|
-
];
|
|
27599
|
-
const genericKeys = /* @__PURE__ */ new Set([
|
|
27600
|
-
"additional",
|
|
27601
|
-
"best",
|
|
27602
|
-
"benefits",
|
|
27603
|
-
"bottomline",
|
|
27604
|
-
"comparison",
|
|
27605
|
-
"conclusion",
|
|
27606
|
-
"directorylisting",
|
|
27607
|
-
"example",
|
|
27608
|
-
"expertise",
|
|
27609
|
-
"features",
|
|
27610
|
-
"finalthoughts",
|
|
27611
|
-
"howitworks",
|
|
27612
|
-
"important",
|
|
27613
|
-
"keybenefits",
|
|
27614
|
-
"keyfeatures",
|
|
27615
|
-
"major",
|
|
27616
|
-
"note",
|
|
27617
|
-
"notable",
|
|
27618
|
-
"option",
|
|
27619
|
-
"other",
|
|
27620
|
-
"overview",
|
|
27621
|
-
"pricing",
|
|
27622
|
-
"pros",
|
|
27623
|
-
"reviews",
|
|
27624
|
-
"step",
|
|
27625
|
-
"summary",
|
|
27626
|
-
"top",
|
|
27627
|
-
"verdict",
|
|
27628
|
-
"whattolookfor",
|
|
27629
|
-
"whyitmatters",
|
|
27630
|
-
"whyitstandsout",
|
|
27631
|
-
"whywechoseit"
|
|
27632
|
-
]);
|
|
27633
|
-
const seen = /* @__PURE__ */ new Map();
|
|
27634
|
-
for (const pattern of candidatePatterns) {
|
|
27635
|
-
let match;
|
|
27636
|
-
while ((match = pattern.exec(answerText)) !== null) {
|
|
27637
|
-
const candidate = cleanCandidateName(match[1] ?? "");
|
|
27638
|
-
const candidateKey = brandKeyFromText(candidate);
|
|
27639
|
-
if (!candidateKey) continue;
|
|
27640
|
-
if (genericKeys.has(candidateKey)) continue;
|
|
27641
|
-
if (candidate.split(/\s+/).length > 6) continue;
|
|
27642
|
-
if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
|
|
27643
|
-
if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
|
|
27644
|
-
if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
|
|
27645
|
-
}
|
|
27646
|
-
}
|
|
27647
|
-
return [...seen.values()].slice(0, 10);
|
|
27648
|
-
}
|
|
27649
|
-
function cleanCandidateName(candidate) {
|
|
27650
|
-
return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
|
|
27651
|
-
}
|
|
27652
|
-
function collectBrandKeysFromDomain(domain) {
|
|
27653
|
-
const reg = registrableDomain(domain);
|
|
27654
|
-
if (!reg) {
|
|
27655
|
-
const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
|
|
27656
|
-
const fallback = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
27657
|
-
return fallback.length >= 4 ? [fallback] : [];
|
|
27658
|
-
}
|
|
27659
|
-
const keys = /* @__PURE__ */ new Set();
|
|
27660
|
-
const fullKey = reg.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
27661
|
-
if (fullKey.length >= 4) keys.add(fullKey);
|
|
27662
|
-
const brand = brandLabelFromDomain(reg).replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
27663
|
-
if (brand.length >= 4) keys.add(brand);
|
|
27664
|
-
return [...keys];
|
|
27665
|
-
}
|
|
27666
|
-
function matchesBrandKey(candidateKey, brandKeys) {
|
|
27667
|
-
for (const brandKey of brandKeys) {
|
|
27668
|
-
if (candidateKey === brandKey) return true;
|
|
27669
|
-
if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
|
|
27670
|
-
if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
|
|
27671
|
-
}
|
|
27672
|
-
return false;
|
|
27673
|
-
}
|
|
27674
|
-
|
|
27675
27800
|
// src/job-runner.ts
|
|
27676
27801
|
var log = createLogger("JobRunner");
|
|
27677
27802
|
var RunCancelledError = class extends Error {
|
|
@@ -29881,7 +30006,7 @@ function readStoredGroundingSources(rawResponse) {
|
|
|
29881
30006
|
return result;
|
|
29882
30007
|
}
|
|
29883
30008
|
async function backfillInsightsCommand(project, opts) {
|
|
29884
|
-
const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-
|
|
30009
|
+
const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-4PT22FED.js");
|
|
29885
30010
|
const config = loadConfig();
|
|
29886
30011
|
const db = createClient(config.database);
|
|
29887
30012
|
migrate(db);
|
|
@@ -32895,7 +33020,7 @@ function buildFallbackRecommendedActions(audit) {
|
|
|
32895
33020
|
function citesTargetDomain(citedDomains, groundingSources, targetDomain) {
|
|
32896
33021
|
const normalizedTarget = extractHostname2(targetDomain);
|
|
32897
33022
|
for (const domain of citedDomains) {
|
|
32898
|
-
if (
|
|
33023
|
+
if (domainMatches(domain, normalizedTarget)) {
|
|
32899
33024
|
return true;
|
|
32900
33025
|
}
|
|
32901
33026
|
}
|
|
@@ -32916,8 +33041,8 @@ function extractCompetitorsFromResponse(ctx) {
|
|
|
32916
33041
|
for (const hint of ctx.manualCompetitors) {
|
|
32917
33042
|
if (isDomainLike(hint)) {
|
|
32918
33043
|
const normalizedHint = normalizeDomain3(hint);
|
|
32919
|
-
if (
|
|
32920
|
-
if (ctx.citedDomains.some((domain) =>
|
|
33044
|
+
if (domainMatches(normalizedHint, targetDomain)) continue;
|
|
33045
|
+
if (ctx.citedDomains.some((domain) => domainMatches(domain, normalizedHint)) || lowerAnswer.includes(normalizedHint.toLowerCase())) {
|
|
32921
33046
|
competitors2.add(normalizedHint);
|
|
32922
33047
|
}
|
|
32923
33048
|
continue;
|
|
@@ -32987,7 +33112,7 @@ function normalizeDomain3(value) {
|
|
|
32987
33112
|
function extractHostname2(value) {
|
|
32988
33113
|
return normalizeDomain3(value);
|
|
32989
33114
|
}
|
|
32990
|
-
function
|
|
33115
|
+
function domainMatches(candidate, target) {
|
|
32991
33116
|
const normalizedCandidate = normalizeDomain3(candidate);
|
|
32992
33117
|
const normalizedTarget = normalizeDomain3(target);
|
|
32993
33118
|
return normalizedCandidate === normalizedTarget || normalizedCandidate.endsWith(`.${normalizedTarget}`);
|
|
@@ -33036,6 +33161,48 @@ function summarizeProviderConfig(provider, config) {
|
|
|
33036
33161
|
function hashApiKey(key) {
|
|
33037
33162
|
return crypto35.createHash("sha256").update(key).digest("hex");
|
|
33038
33163
|
}
|
|
33164
|
+
var DASHBOARD_SCRYPT_KEYLEN = 64;
|
|
33165
|
+
var DASHBOARD_SCRYPT_COST = 1 << 15;
|
|
33166
|
+
var DASHBOARD_SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
|
33167
|
+
function hashDashboardPassword(password) {
|
|
33168
|
+
const salt = crypto35.randomBytes(16);
|
|
33169
|
+
const derived = crypto35.scryptSync(password, salt, DASHBOARD_SCRYPT_KEYLEN, {
|
|
33170
|
+
N: DASHBOARD_SCRYPT_COST,
|
|
33171
|
+
maxmem: DASHBOARD_SCRYPT_MAXMEM
|
|
33172
|
+
});
|
|
33173
|
+
return `scrypt$1$${salt.toString("base64")}$${derived.toString("base64")}`;
|
|
33174
|
+
}
|
|
33175
|
+
function verifyDashboardPassword(password, storedHash) {
|
|
33176
|
+
if (storedHash.startsWith("scrypt$1$")) {
|
|
33177
|
+
const parts = storedHash.split("$");
|
|
33178
|
+
if (parts.length !== 4) return { ok: false, needsRehash: false };
|
|
33179
|
+
const saltB64 = parts[2];
|
|
33180
|
+
const hashB64 = parts[3];
|
|
33181
|
+
if (!saltB64 || !hashB64) return { ok: false, needsRehash: false };
|
|
33182
|
+
let salt;
|
|
33183
|
+
let expected;
|
|
33184
|
+
try {
|
|
33185
|
+
salt = Buffer.from(saltB64, "base64");
|
|
33186
|
+
expected = Buffer.from(hashB64, "base64");
|
|
33187
|
+
} catch {
|
|
33188
|
+
return { ok: false, needsRehash: false };
|
|
33189
|
+
}
|
|
33190
|
+
const derived = crypto35.scryptSync(password, salt, expected.length, {
|
|
33191
|
+
N: DASHBOARD_SCRYPT_COST,
|
|
33192
|
+
maxmem: DASHBOARD_SCRYPT_MAXMEM
|
|
33193
|
+
});
|
|
33194
|
+
if (derived.length !== expected.length) return { ok: false, needsRehash: false };
|
|
33195
|
+
return { ok: crypto35.timingSafeEqual(derived, expected), needsRehash: false };
|
|
33196
|
+
}
|
|
33197
|
+
if (/^[a-f0-9]{64}$/i.test(storedHash)) {
|
|
33198
|
+
const candidate = Buffer.from(hashApiKey(password), "hex");
|
|
33199
|
+
const expected = Buffer.from(storedHash, "hex");
|
|
33200
|
+
if (candidate.length !== expected.length) return { ok: false, needsRehash: false };
|
|
33201
|
+
const ok = crypto35.timingSafeEqual(candidate, expected);
|
|
33202
|
+
return { ok, needsRehash: ok };
|
|
33203
|
+
}
|
|
33204
|
+
return { ok: false, needsRehash: false };
|
|
33205
|
+
}
|
|
33039
33206
|
function parseCookies2(header) {
|
|
33040
33207
|
if (!header) return {};
|
|
33041
33208
|
return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
|
|
@@ -33264,13 +33431,14 @@ async function createServer(opts) {
|
|
|
33264
33431
|
if (!opts.config.bing) opts.config.bing = {};
|
|
33265
33432
|
if (!opts.config.bing.connections) opts.config.bing.connections = [];
|
|
33266
33433
|
const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
|
|
33434
|
+
const normalized = { ...connection, createdByProjectId: connection.createdByProjectId ?? null };
|
|
33267
33435
|
if (idx >= 0) {
|
|
33268
|
-
opts.config.bing.connections[idx] =
|
|
33436
|
+
opts.config.bing.connections[idx] = normalized;
|
|
33269
33437
|
} else {
|
|
33270
|
-
opts.config.bing.connections.push(
|
|
33438
|
+
opts.config.bing.connections.push(normalized);
|
|
33271
33439
|
}
|
|
33272
33440
|
saveConfigPatch(opts.config);
|
|
33273
|
-
return
|
|
33441
|
+
return normalized;
|
|
33274
33442
|
},
|
|
33275
33443
|
updateConnection: (domain, patch) => {
|
|
33276
33444
|
const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
|
|
@@ -33478,7 +33646,7 @@ async function createServer(opts) {
|
|
|
33478
33646
|
const err = validationError("Password must be at least 8 characters");
|
|
33479
33647
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
33480
33648
|
}
|
|
33481
|
-
opts.config.dashboardPasswordHash =
|
|
33649
|
+
opts.config.dashboardPasswordHash = hashDashboardPassword(password);
|
|
33482
33650
|
saveConfigPatch(opts.config);
|
|
33483
33651
|
if (!createPasswordSession(reply)) {
|
|
33484
33652
|
const err = authInvalid();
|
|
@@ -33494,9 +33662,14 @@ async function createServer(opts) {
|
|
|
33494
33662
|
const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
|
|
33495
33663
|
return reply.status(err2.statusCode).send(err2.toJSON());
|
|
33496
33664
|
}
|
|
33497
|
-
|
|
33665
|
+
const verification = verifyDashboardPassword(password, opts.config.dashboardPasswordHash);
|
|
33666
|
+
if (!verification.ok) {
|
|
33498
33667
|
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
|
|
33499
33668
|
}
|
|
33669
|
+
if (verification.needsRehash) {
|
|
33670
|
+
opts.config.dashboardPasswordHash = hashDashboardPassword(password);
|
|
33671
|
+
saveConfigPatch(opts.config);
|
|
33672
|
+
}
|
|
33500
33673
|
if (!createPasswordSession(reply)) {
|
|
33501
33674
|
return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
|
|
33502
33675
|
}
|