@ainyc/canonry 4.31.0 → 4.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/{index-C4UBTDDS.js → index-CUMjedc6.js} +106 -106
- package/assets/index.html +1 -1
- package/dist/{chunk-HTNC6AWN.js → chunk-5M4PP6P4.js} +1 -0
- package/dist/{chunk-PUTJHEVR.js → chunk-7I65IXVU.js} +490 -13
- package/dist/{chunk-U3YKRV47.js → chunk-LUAJVZVZ.js} +1 -1
- package/dist/{chunk-5STLZRGB.js → chunk-LVX5TOYA.js} +24 -1
- package/dist/cli.js +116 -6
- package/dist/index.d.ts +20 -0
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-CJONZ7ST.js → intelligence-service-RSRWDBHS.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +9 -9
package/assets/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
|
|
13
13
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
14
14
|
<title>Canonry</title>
|
|
15
|
-
<script type="module" crossorigin src="./assets/index-
|
|
15
|
+
<script type="module" crossorigin src="./assets/index-CUMjedc6.js"></script>
|
|
16
16
|
<link rel="stylesheet" crossorigin href="./assets/index-CNKAwZMB.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
loadConfig,
|
|
6
6
|
loadConfigRaw,
|
|
7
7
|
saveConfigPatch
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-LVX5TOYA.js";
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_RUN_HISTORY_LIMIT,
|
|
11
11
|
IntelligenceService,
|
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
schedules,
|
|
71
71
|
trafficSources,
|
|
72
72
|
usageCounters
|
|
73
|
-
} from "./chunk-
|
|
73
|
+
} from "./chunk-LUAJVZVZ.js";
|
|
74
74
|
import {
|
|
75
75
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
76
76
|
AGENT_PROVIDER_IDS,
|
|
@@ -171,13 +171,14 @@ import {
|
|
|
171
171
|
serializeRunError,
|
|
172
172
|
snapshotRequestSchema,
|
|
173
173
|
summarizeCheckResults,
|
|
174
|
+
trafficConnectVercelRequestSchema,
|
|
174
175
|
trafficConnectWordpressRequestSchema,
|
|
175
176
|
unsupportedKind,
|
|
176
177
|
validationError,
|
|
177
178
|
visibilityStateFromAnswerMentioned,
|
|
178
179
|
windowCutoff,
|
|
179
180
|
wordpressEnvSchema
|
|
180
|
-
} from "./chunk-
|
|
181
|
+
} from "./chunk-5M4PP6P4.js";
|
|
181
182
|
|
|
182
183
|
// src/telemetry.ts
|
|
183
184
|
import crypto from "crypto";
|
|
@@ -10234,6 +10235,38 @@ var routeCatalog = [
|
|
|
10234
10235
|
502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
|
|
10235
10236
|
}
|
|
10236
10237
|
},
|
|
10238
|
+
{
|
|
10239
|
+
method: "post",
|
|
10240
|
+
path: "/api/v1/projects/{name}/traffic/connect/vercel",
|
|
10241
|
+
summary: "Connect a Vercel traffic source",
|
|
10242
|
+
description: "Probes Vercel's internal `request-logs` endpoint with the supplied API 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 API token lives in the credential file.",
|
|
10243
|
+
tags: ["traffic"],
|
|
10244
|
+
parameters: [nameParameter],
|
|
10245
|
+
requestBody: {
|
|
10246
|
+
required: true,
|
|
10247
|
+
content: {
|
|
10248
|
+
"application/json": {
|
|
10249
|
+
schema: {
|
|
10250
|
+
type: "object",
|
|
10251
|
+
required: ["projectId", "teamId", "token"],
|
|
10252
|
+
properties: {
|
|
10253
|
+
projectId: { ...stringSchema, description: "Vercel project id (e.g. `prj_...`) \u2014 from the Vercel dashboard or `.vercel/project.json`." },
|
|
10254
|
+
teamId: { ...stringSchema, description: "Vercel team / owner id (e.g. `team_...`)." },
|
|
10255
|
+
token: { ...stringSchema, description: "Vercel API token (personal access token). Stored in `~/.canonry/config.yaml`, never the DB or response." },
|
|
10256
|
+
environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
|
|
10257
|
+
displayName: stringSchema
|
|
10258
|
+
}
|
|
10259
|
+
}
|
|
10260
|
+
}
|
|
10261
|
+
}
|
|
10262
|
+
},
|
|
10263
|
+
responses: {
|
|
10264
|
+
200: { description: "Traffic source DTO returned." },
|
|
10265
|
+
400: { description: "Invalid Vercel connection request." },
|
|
10266
|
+
404: { description: "Project not found." },
|
|
10267
|
+
502: { description: "Vercel request-logs endpoint probe failed (bad token, wrong project / team id, unreachable host, etc.)." }
|
|
10268
|
+
}
|
|
10269
|
+
},
|
|
10237
10270
|
{
|
|
10238
10271
|
method: "post",
|
|
10239
10272
|
path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
|
|
@@ -17607,13 +17640,194 @@ async function listWordpressTrafficEvents(options) {
|
|
|
17607
17640
|
};
|
|
17608
17641
|
}
|
|
17609
17642
|
|
|
17643
|
+
// ../integration-vercel/src/normalize.ts
|
|
17644
|
+
function numberOrNull2(value) {
|
|
17645
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
17646
|
+
return value;
|
|
17647
|
+
}
|
|
17648
|
+
function resolveStatus(row) {
|
|
17649
|
+
if (typeof row.statusCode === "number" && row.statusCode >= 100) {
|
|
17650
|
+
return row.statusCode;
|
|
17651
|
+
}
|
|
17652
|
+
const events = row.events ?? [];
|
|
17653
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
17654
|
+
const status = events[i]?.httpStatus;
|
|
17655
|
+
if (typeof status === "number" && status >= 100) return status;
|
|
17656
|
+
}
|
|
17657
|
+
return null;
|
|
17658
|
+
}
|
|
17659
|
+
function serializeSearchParams(params) {
|
|
17660
|
+
if (!params) return null;
|
|
17661
|
+
const entries = Object.entries(params).filter(
|
|
17662
|
+
(entry) => typeof entry[1] === "string"
|
|
17663
|
+
);
|
|
17664
|
+
if (entries.length === 0) return null;
|
|
17665
|
+
return new URLSearchParams(entries).toString();
|
|
17666
|
+
}
|
|
17667
|
+
function emptyToNull(value) {
|
|
17668
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
17669
|
+
return value;
|
|
17670
|
+
}
|
|
17671
|
+
function stringLabels(input) {
|
|
17672
|
+
return Object.fromEntries(
|
|
17673
|
+
Object.entries(input).filter(
|
|
17674
|
+
(entry) => typeof entry[1] === "string" && entry[1] !== ""
|
|
17675
|
+
)
|
|
17676
|
+
);
|
|
17677
|
+
}
|
|
17678
|
+
function normalizeVercelLogRow(row) {
|
|
17679
|
+
const path15 = row.requestPath;
|
|
17680
|
+
if (!path15) return null;
|
|
17681
|
+
const observedAt = row.timestamp;
|
|
17682
|
+
if (!observedAt) return null;
|
|
17683
|
+
const requestId = row.requestId;
|
|
17684
|
+
if (!requestId) return null;
|
|
17685
|
+
const host = emptyToNull(row.domain);
|
|
17686
|
+
const queryString = serializeSearchParams(row.requestSearchParams);
|
|
17687
|
+
const requestUrl = host ? `https://${host}${path15}${queryString ? `?${queryString}` : ""}` : null;
|
|
17688
|
+
return {
|
|
17689
|
+
sourceType: TrafficSourceTypes.vercel,
|
|
17690
|
+
evidenceKind: TrafficEvidenceKinds["raw-request"],
|
|
17691
|
+
confidence: TrafficEventConfidences.observed,
|
|
17692
|
+
eventId: `vercel:${observedAt}:${requestId}`,
|
|
17693
|
+
observedAt,
|
|
17694
|
+
method: row.requestMethod ?? null,
|
|
17695
|
+
requestUrl,
|
|
17696
|
+
host,
|
|
17697
|
+
path: path15,
|
|
17698
|
+
queryString,
|
|
17699
|
+
status: resolveStatus(row),
|
|
17700
|
+
userAgent: emptyToNull(row.clientUserAgent),
|
|
17701
|
+
// The request-logs endpoint does not expose a client IP; UA-only matches
|
|
17702
|
+
// stay `claimed_unverified` in the classifier.
|
|
17703
|
+
remoteIp: null,
|
|
17704
|
+
referer: emptyToNull(row.requestReferer),
|
|
17705
|
+
latencyMs: numberOrNull2(row.requestDurationMs),
|
|
17706
|
+
requestSizeBytes: null,
|
|
17707
|
+
responseSizeBytes: null,
|
|
17708
|
+
providerResource: {
|
|
17709
|
+
type: "vercel_deployment",
|
|
17710
|
+
labels: stringLabels({
|
|
17711
|
+
deploymentId: row.deploymentId,
|
|
17712
|
+
environment: row.environment,
|
|
17713
|
+
region: row.clientRegion
|
|
17714
|
+
})
|
|
17715
|
+
},
|
|
17716
|
+
providerLabels: stringLabels({
|
|
17717
|
+
branch: row.branch,
|
|
17718
|
+
cache: row.cache
|
|
17719
|
+
})
|
|
17720
|
+
};
|
|
17721
|
+
}
|
|
17722
|
+
|
|
17723
|
+
// ../integration-vercel/src/client.ts
|
|
17724
|
+
var VERCEL_REQUEST_LOGS_URL = "https://vercel.com/api/logs/request-logs";
|
|
17725
|
+
var DEFAULT_ENVIRONMENT = "production";
|
|
17726
|
+
var DEFAULT_MAX_PAGES3 = 1;
|
|
17727
|
+
var DEFAULT_TIMEOUT_MS3 = 3e4;
|
|
17728
|
+
var VercelLogsApiError = class extends Error {
|
|
17729
|
+
constructor(message, status, body) {
|
|
17730
|
+
super(message);
|
|
17731
|
+
this.status = status;
|
|
17732
|
+
this.body = body;
|
|
17733
|
+
this.name = "VercelLogsApiError";
|
|
17734
|
+
}
|
|
17735
|
+
};
|
|
17736
|
+
function trimRequired2(name, value) {
|
|
17737
|
+
const trimmed = value.trim();
|
|
17738
|
+
if (!trimmed) {
|
|
17739
|
+
throw new VercelLogsApiError(`${name} is required`, 400);
|
|
17740
|
+
}
|
|
17741
|
+
return trimmed;
|
|
17742
|
+
}
|
|
17743
|
+
function normalizeMaxPages3(maxPages) {
|
|
17744
|
+
if (maxPages === void 0) return DEFAULT_MAX_PAGES3;
|
|
17745
|
+
if (!Number.isInteger(maxPages) || maxPages < 1) {
|
|
17746
|
+
throw new VercelLogsApiError("maxPages must be a positive integer", 400);
|
|
17747
|
+
}
|
|
17748
|
+
return maxPages;
|
|
17749
|
+
}
|
|
17750
|
+
function toEpochMs(label, value) {
|
|
17751
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === "number" ? value : new Date(value).getTime();
|
|
17752
|
+
if (!Number.isFinite(ms)) {
|
|
17753
|
+
throw new VercelLogsApiError(`${label} must be a valid date`, 400);
|
|
17754
|
+
}
|
|
17755
|
+
return String(Math.trunc(ms));
|
|
17756
|
+
}
|
|
17757
|
+
async function readErrorBody3(response) {
|
|
17758
|
+
const text = await response.text().catch(() => "");
|
|
17759
|
+
if (!text) return void 0;
|
|
17760
|
+
return text.length <= 500 ? text : `${text.slice(0, 500)}... [truncated]`;
|
|
17761
|
+
}
|
|
17762
|
+
async function listVercelTrafficEvents(options) {
|
|
17763
|
+
const token = trimRequired2("token", options.token);
|
|
17764
|
+
const projectId = trimRequired2("projectId", options.projectId);
|
|
17765
|
+
const teamId = trimRequired2("teamId", options.teamId);
|
|
17766
|
+
const environment = options.environment ?? DEFAULT_ENVIRONMENT;
|
|
17767
|
+
const startDate = toEpochMs("startDate", options.startDate);
|
|
17768
|
+
const endDate = toEpochMs("endDate", options.endDate);
|
|
17769
|
+
const maxPages = normalizeMaxPages3(options.maxPages);
|
|
17770
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
|
|
17771
|
+
let rawEntryCount = 0;
|
|
17772
|
+
let skippedEntryCount = 0;
|
|
17773
|
+
let hasMore = false;
|
|
17774
|
+
const events = [];
|
|
17775
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
17776
|
+
const url = new URL(VERCEL_REQUEST_LOGS_URL);
|
|
17777
|
+
url.searchParams.set("projectId", projectId);
|
|
17778
|
+
url.searchParams.set("ownerId", teamId);
|
|
17779
|
+
url.searchParams.set("teamId", teamId);
|
|
17780
|
+
url.searchParams.set("page", String(page));
|
|
17781
|
+
url.searchParams.set("startDate", startDate);
|
|
17782
|
+
url.searchParams.set("endDate", endDate);
|
|
17783
|
+
url.searchParams.set("environment", environment);
|
|
17784
|
+
const response = await fetch(url, {
|
|
17785
|
+
method: "GET",
|
|
17786
|
+
headers: {
|
|
17787
|
+
Authorization: `Bearer ${token}`,
|
|
17788
|
+
Accept: "application/json"
|
|
17789
|
+
},
|
|
17790
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
17791
|
+
});
|
|
17792
|
+
if (!response.ok) {
|
|
17793
|
+
const body2 = await readErrorBody3(response);
|
|
17794
|
+
throw new VercelLogsApiError(
|
|
17795
|
+
`Vercel request-logs endpoint returned HTTP ${response.status}`,
|
|
17796
|
+
response.status,
|
|
17797
|
+
body2
|
|
17798
|
+
);
|
|
17799
|
+
}
|
|
17800
|
+
const body = await response.json();
|
|
17801
|
+
const rows = body.rows ?? [];
|
|
17802
|
+
rawEntryCount += rows.length;
|
|
17803
|
+
for (const row of rows) {
|
|
17804
|
+
const event = normalizeVercelLogRow(row);
|
|
17805
|
+
if (event) {
|
|
17806
|
+
events.push(event);
|
|
17807
|
+
} else {
|
|
17808
|
+
skippedEntryCount += 1;
|
|
17809
|
+
}
|
|
17810
|
+
}
|
|
17811
|
+
hasMore = Boolean(body.hasMoreRows);
|
|
17812
|
+
if (!hasMore) break;
|
|
17813
|
+
}
|
|
17814
|
+
return {
|
|
17815
|
+
events,
|
|
17816
|
+
rawEntryCount,
|
|
17817
|
+
skippedEntryCount,
|
|
17818
|
+
hasMore,
|
|
17819
|
+
endpoint: VERCEL_REQUEST_LOGS_URL
|
|
17820
|
+
};
|
|
17821
|
+
}
|
|
17822
|
+
|
|
17610
17823
|
// ../api-routes/src/traffic.ts
|
|
17611
17824
|
var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
|
|
17612
17825
|
var DEFAULT_PAGE_SIZE3 = 1e3;
|
|
17613
|
-
var
|
|
17826
|
+
var DEFAULT_MAX_PAGES4 = 5;
|
|
17614
17827
|
var DEFAULT_SAMPLE_LIMIT2 = 100;
|
|
17615
17828
|
var DEFAULT_WP_PAGE_SIZE = 500;
|
|
17616
17829
|
var DEFAULT_WP_MAX_PAGES = 20;
|
|
17830
|
+
var DEFAULT_VERCEL_MAX_PAGES = 50;
|
|
17617
17831
|
var MAX_TRACKED_EVENT_IDS = 1e3;
|
|
17618
17832
|
var DEFAULT_BACKFILL_DAYS = 30;
|
|
17619
17833
|
var MAX_BACKFILL_DAYS = 30;
|
|
@@ -17794,9 +18008,11 @@ async function trafficRoutes(app, opts) {
|
|
|
17794
18008
|
const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
|
|
17795
18009
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
17796
18010
|
const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
|
|
18011
|
+
const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
18012
|
+
const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
|
|
17797
18013
|
const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
|
|
17798
18014
|
const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
|
|
17799
|
-
const maxPages = opts.defaultMaxPages ??
|
|
18015
|
+
const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES4;
|
|
17800
18016
|
const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
|
|
17801
18017
|
app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
|
|
17802
18018
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -17958,15 +18174,98 @@ async function trafficRoutes(app, opts) {
|
|
|
17958
18174
|
});
|
|
17959
18175
|
return rowToDto(sourceRow);
|
|
17960
18176
|
});
|
|
18177
|
+
app.post("/projects/:name/traffic/connect/vercel", async (request) => {
|
|
18178
|
+
const project = resolveProject(app.db, request.params.name);
|
|
18179
|
+
if (!opts.vercelTrafficCredentialStore) {
|
|
18180
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18181
|
+
}
|
|
18182
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18183
|
+
const parsed = trafficConnectVercelRequestSchema.safeParse(request.body ?? {});
|
|
18184
|
+
if (!parsed.success) {
|
|
18185
|
+
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
18186
|
+
}
|
|
18187
|
+
const { projectId, teamId, token, displayName } = parsed.data;
|
|
18188
|
+
const environment = parsed.data.environment ?? "production";
|
|
18189
|
+
const probeEnd = Date.now();
|
|
18190
|
+
try {
|
|
18191
|
+
await pullVercelEvents({
|
|
18192
|
+
token,
|
|
18193
|
+
projectId,
|
|
18194
|
+
teamId,
|
|
18195
|
+
environment,
|
|
18196
|
+
startDate: probeEnd - 60 * 6e4,
|
|
18197
|
+
endDate: probeEnd,
|
|
18198
|
+
maxPages: 1
|
|
18199
|
+
});
|
|
18200
|
+
} catch (e) {
|
|
18201
|
+
if (e instanceof VercelLogsApiError) {
|
|
18202
|
+
throw providerError(
|
|
18203
|
+
`Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
18204
|
+
);
|
|
18205
|
+
}
|
|
18206
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18207
|
+
throw providerError(`Vercel traffic probe failed: ${msg}`);
|
|
18208
|
+
}
|
|
18209
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
18210
|
+
const existing = credentialStore.getConnection(project.name);
|
|
18211
|
+
credentialStore.upsertConnection({
|
|
18212
|
+
projectName: project.name,
|
|
18213
|
+
projectId,
|
|
18214
|
+
teamId,
|
|
18215
|
+
token,
|
|
18216
|
+
environment,
|
|
18217
|
+
createdAt: existing?.createdAt ?? now,
|
|
18218
|
+
updatedAt: now
|
|
18219
|
+
});
|
|
18220
|
+
const activeSource = app.db.select().from(trafficSources).where(eq23(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.vercel && row.status !== TrafficSourceStatuses.archived);
|
|
18221
|
+
const config = { projectId, teamId, environment };
|
|
18222
|
+
const fallbackName = displayName ?? `Vercel \xB7 ${projectId}`;
|
|
18223
|
+
let sourceRow;
|
|
18224
|
+
if (activeSource) {
|
|
18225
|
+
app.db.update(trafficSources).set({
|
|
18226
|
+
displayName: fallbackName,
|
|
18227
|
+
status: TrafficSourceStatuses.connected,
|
|
18228
|
+
lastError: null,
|
|
18229
|
+
configJson: JSON.stringify(config),
|
|
18230
|
+
updatedAt: now
|
|
18231
|
+
}).where(eq23(trafficSources.id, activeSource.id)).run();
|
|
18232
|
+
sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, activeSource.id)).get();
|
|
18233
|
+
} else {
|
|
18234
|
+
const newId = crypto20.randomUUID();
|
|
18235
|
+
app.db.insert(trafficSources).values({
|
|
18236
|
+
id: newId,
|
|
18237
|
+
projectId: project.id,
|
|
18238
|
+
sourceType: TrafficSourceTypes.vercel,
|
|
18239
|
+
displayName: fallbackName,
|
|
18240
|
+
status: TrafficSourceStatuses.connected,
|
|
18241
|
+
lastSyncedAt: null,
|
|
18242
|
+
lastCursor: null,
|
|
18243
|
+
lastError: null,
|
|
18244
|
+
archivedAt: null,
|
|
18245
|
+
configJson: JSON.stringify(config),
|
|
18246
|
+
createdAt: now,
|
|
18247
|
+
updatedAt: now
|
|
18248
|
+
}).run();
|
|
18249
|
+
sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, newId)).get();
|
|
18250
|
+
}
|
|
18251
|
+
writeAuditLog(app.db, {
|
|
18252
|
+
projectId: project.id,
|
|
18253
|
+
actor: "api",
|
|
18254
|
+
action: "traffic.vercel.connected",
|
|
18255
|
+
entityType: "traffic_source",
|
|
18256
|
+
entityId: sourceRow.id
|
|
18257
|
+
});
|
|
18258
|
+
return rowToDto(sourceRow);
|
|
18259
|
+
});
|
|
17961
18260
|
app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
|
|
17962
18261
|
const project = resolveProject(app.db, request.params.name);
|
|
17963
18262
|
const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
|
|
17964
18263
|
if (!sourceRow || sourceRow.projectId !== project.id) {
|
|
17965
18264
|
throw notFound("Traffic source", request.params.id);
|
|
17966
18265
|
}
|
|
17967
|
-
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
|
|
18266
|
+
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
|
|
17968
18267
|
throw validationError(
|
|
17969
|
-
`Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and
|
|
18268
|
+
`Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
|
|
17970
18269
|
);
|
|
17971
18270
|
}
|
|
17972
18271
|
const windowEnd = /* @__PURE__ */ new Date();
|
|
@@ -18056,7 +18355,7 @@ async function trafficRoutes(app, opts) {
|
|
|
18056
18355
|
markFailed(msg, "PROVIDER_PULL");
|
|
18057
18356
|
throw providerError(`Cloud Run pull failed: ${msg}`);
|
|
18058
18357
|
}
|
|
18059
|
-
} else {
|
|
18358
|
+
} else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
|
|
18060
18359
|
auditAction = "traffic.wordpress.synced";
|
|
18061
18360
|
const credentialStore = opts.wordpressTrafficCredentialStore;
|
|
18062
18361
|
if (!credentialStore) {
|
|
@@ -18097,6 +18396,53 @@ async function trafficRoutes(app, opts) {
|
|
|
18097
18396
|
markFailed(msg, "PROVIDER_PULL");
|
|
18098
18397
|
throw providerError(`WordPress pull failed: ${msg}`);
|
|
18099
18398
|
}
|
|
18399
|
+
} else {
|
|
18400
|
+
auditAction = "traffic.vercel.synced";
|
|
18401
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18402
|
+
if (!credentialStore) {
|
|
18403
|
+
app.db.delete(runs).where(eq23(runs.id, runId)).run();
|
|
18404
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18405
|
+
}
|
|
18406
|
+
const credential = credentialStore.getConnection(project.name);
|
|
18407
|
+
if (!credential) {
|
|
18408
|
+
app.db.delete(runs).where(eq23(runs.id, runId)).run();
|
|
18409
|
+
throw validationError(
|
|
18410
|
+
`No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
|
|
18411
|
+
);
|
|
18412
|
+
}
|
|
18413
|
+
const config = parseSourceConfig(sourceRow);
|
|
18414
|
+
const vercelProjectId = config.projectId ?? credential.projectId;
|
|
18415
|
+
const vercelTeamId = config.teamId ?? credential.teamId;
|
|
18416
|
+
const vercelEnvironment = config.environment ?? credential.environment;
|
|
18417
|
+
const requestedMinutes = request.body?.sinceMinutes;
|
|
18418
|
+
const windowMinutes = Number.isFinite(requestedMinutes) && requestedMinutes && requestedMinutes > 0 ? Math.floor(requestedMinutes) : syncWindowMinutes;
|
|
18419
|
+
const requestedStartMs = windowEnd.getTime() - windowMinutes * 6e4;
|
|
18420
|
+
const lastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
|
|
18421
|
+
windowStart = new Date(
|
|
18422
|
+
Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
|
|
18423
|
+
);
|
|
18424
|
+
let page;
|
|
18425
|
+
try {
|
|
18426
|
+
page = await pullVercelEvents({
|
|
18427
|
+
token: credential.token,
|
|
18428
|
+
projectId: vercelProjectId,
|
|
18429
|
+
teamId: vercelTeamId,
|
|
18430
|
+
environment: vercelEnvironment,
|
|
18431
|
+
startDate: windowStart.getTime(),
|
|
18432
|
+
endDate: windowEnd.getTime(),
|
|
18433
|
+
maxPages: vercelMaxPages
|
|
18434
|
+
});
|
|
18435
|
+
} catch (e) {
|
|
18436
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18437
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
18438
|
+
throw providerError(`Vercel pull failed: ${msg}`);
|
|
18439
|
+
}
|
|
18440
|
+
if (page.hasMore) {
|
|
18441
|
+
const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
|
|
18442
|
+
markFailed(msg, "PROVIDER_PULL");
|
|
18443
|
+
throw providerError(`Vercel pull failed: ${msg}`);
|
|
18444
|
+
}
|
|
18445
|
+
allEvents = page.events;
|
|
18100
18446
|
}
|
|
18101
18447
|
let crawlerBucketRows = 0;
|
|
18102
18448
|
let aiReferralBucketRows = 0;
|
|
@@ -18281,9 +18627,9 @@ async function trafficRoutes(app, opts) {
|
|
|
18281
18627
|
if (!sourceRow || sourceRow.projectId !== project.id) {
|
|
18282
18628
|
throw notFound("Traffic source", request.params.id);
|
|
18283
18629
|
}
|
|
18284
|
-
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
|
|
18630
|
+
if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
|
|
18285
18631
|
throw validationError(
|
|
18286
|
-
`Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and
|
|
18632
|
+
`Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
|
|
18287
18633
|
);
|
|
18288
18634
|
}
|
|
18289
18635
|
const requestedDays = request.body?.days ?? DEFAULT_BACKFILL_DAYS;
|
|
@@ -18331,7 +18677,7 @@ async function trafficRoutes(app, opts) {
|
|
|
18331
18677
|
});
|
|
18332
18678
|
return page.events;
|
|
18333
18679
|
};
|
|
18334
|
-
} else {
|
|
18680
|
+
} else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
|
|
18335
18681
|
const credentialStore = opts.wordpressTrafficCredentialStore;
|
|
18336
18682
|
if (!credentialStore) {
|
|
18337
18683
|
throw validationError("WordPress traffic credential storage is not configured for this deployment");
|
|
@@ -18370,6 +18716,39 @@ async function trafficRoutes(app, opts) {
|
|
|
18370
18716
|
}
|
|
18371
18717
|
return collected;
|
|
18372
18718
|
};
|
|
18719
|
+
} else {
|
|
18720
|
+
const credentialStore = opts.vercelTrafficCredentialStore;
|
|
18721
|
+
if (!credentialStore) {
|
|
18722
|
+
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
18723
|
+
}
|
|
18724
|
+
const credential = credentialStore.getConnection(project.name);
|
|
18725
|
+
if (!credential) {
|
|
18726
|
+
throw validationError(
|
|
18727
|
+
`No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
|
|
18728
|
+
);
|
|
18729
|
+
}
|
|
18730
|
+
const config = parseSourceConfig(sourceRow);
|
|
18731
|
+
const vercelProjectId = config.projectId ?? credential.projectId;
|
|
18732
|
+
const vercelTeamId = config.teamId ?? credential.teamId;
|
|
18733
|
+
const vercelEnvironment = config.environment ?? credential.environment;
|
|
18734
|
+
pullErrorPrefix = "Vercel pull failed";
|
|
18735
|
+
pullForBackfill = async () => {
|
|
18736
|
+
const page = await pullVercelEvents({
|
|
18737
|
+
token: credential.token,
|
|
18738
|
+
projectId: vercelProjectId,
|
|
18739
|
+
teamId: vercelTeamId,
|
|
18740
|
+
environment: vercelEnvironment,
|
|
18741
|
+
startDate: windowStart.getTime(),
|
|
18742
|
+
endDate: windowEnd.getTime(),
|
|
18743
|
+
maxPages: BACKFILL_MAX_PAGES
|
|
18744
|
+
});
|
|
18745
|
+
if (page.hasMore) {
|
|
18746
|
+
throw new Error(
|
|
18747
|
+
`backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
|
|
18748
|
+
);
|
|
18749
|
+
}
|
|
18750
|
+
return page.events;
|
|
18751
|
+
};
|
|
18373
18752
|
}
|
|
18374
18753
|
const startedAt = windowEnd.toISOString();
|
|
18375
18754
|
const runId = crypto20.randomUUID();
|
|
@@ -20169,6 +20548,8 @@ async function apiRoutes(app, opts) {
|
|
|
20169
20548
|
resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
|
|
20170
20549
|
wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
|
|
20171
20550
|
pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
|
|
20551
|
+
vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
|
|
20552
|
+
pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
|
|
20172
20553
|
onTrafficSynced: opts.onTrafficSynced
|
|
20173
20554
|
});
|
|
20174
20555
|
await api.register(backlinksRoutes, {
|
|
@@ -20282,6 +20663,54 @@ function buildTrafficSourceValidators(opts) {
|
|
|
20282
20663
|
validateScopes: () => null
|
|
20283
20664
|
};
|
|
20284
20665
|
}
|
|
20666
|
+
if (opts.vercelTrafficCredentialStore) {
|
|
20667
|
+
const store = opts.vercelTrafficCredentialStore;
|
|
20668
|
+
const pullEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
|
|
20669
|
+
validators[TrafficSourceTypes.vercel] = {
|
|
20670
|
+
validateCredentials: async (source) => {
|
|
20671
|
+
const record = store.getConnection(source.projectName);
|
|
20672
|
+
if (!record) {
|
|
20673
|
+
return {
|
|
20674
|
+
status: CheckStatuses.fail,
|
|
20675
|
+
code: "traffic.credentials.missing",
|
|
20676
|
+
summary: `No Vercel credential found in ~/.canonry/config.yaml for project "${source.projectName}".`,
|
|
20677
|
+
remediation: "Re-run `canonry traffic connect vercel <project> --project-id <prj> --team-id <team> --token <token>`."
|
|
20678
|
+
};
|
|
20679
|
+
}
|
|
20680
|
+
try {
|
|
20681
|
+
const probeEnd = Date.now();
|
|
20682
|
+
await pullEvents({
|
|
20683
|
+
token: record.token,
|
|
20684
|
+
projectId: record.projectId,
|
|
20685
|
+
teamId: record.teamId,
|
|
20686
|
+
environment: record.environment,
|
|
20687
|
+
startDate: probeEnd - 60 * 6e4,
|
|
20688
|
+
endDate: probeEnd,
|
|
20689
|
+
maxPages: 1
|
|
20690
|
+
});
|
|
20691
|
+
return {
|
|
20692
|
+
status: CheckStatuses.ok,
|
|
20693
|
+
code: "traffic.credentials.resolved",
|
|
20694
|
+
summary: `Vercel request-logs responds for "${source.displayName}" (project ${record.projectId}).`
|
|
20695
|
+
};
|
|
20696
|
+
} catch (e) {
|
|
20697
|
+
const httpStatus = e instanceof VercelLogsApiError ? e.status : null;
|
|
20698
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
20699
|
+
return {
|
|
20700
|
+
status: CheckStatuses.fail,
|
|
20701
|
+
code: httpStatus === 401 || httpStatus === 403 ? "traffic.credentials.unauthorized" : "traffic.credentials.resolve-failed",
|
|
20702
|
+
summary: httpStatus ? `Vercel request-logs returned HTTP ${httpStatus}: ${msg}.` : `Vercel request-logs probe failed: ${msg}.`,
|
|
20703
|
+
remediation: "Verify the Vercel API token is unexpired and the project / team ids are correct. Vercel tokens can expire \u2014 re-connect the source with a fresh token if needed."
|
|
20704
|
+
};
|
|
20705
|
+
}
|
|
20706
|
+
},
|
|
20707
|
+
// Vercel API tokens have no granular per-resource scopes — a token
|
|
20708
|
+
// inherits the user's team access, so there is no "missing scope"
|
|
20709
|
+
// failure mode. Surface a skipped result so the framework stays
|
|
20710
|
+
// uniform without producing a false signal.
|
|
20711
|
+
validateScopes: () => null
|
|
20712
|
+
};
|
|
20713
|
+
}
|
|
20285
20714
|
return Object.keys(validators).length > 0 ? validators : void 0;
|
|
20286
20715
|
}
|
|
20287
20716
|
|
|
@@ -22799,8 +23228,40 @@ function removeWordpressTrafficConnection(config, projectName) {
|
|
|
22799
23228
|
return true;
|
|
22800
23229
|
}
|
|
22801
23230
|
|
|
22802
|
-
// src/
|
|
23231
|
+
// src/vercel-traffic-config.ts
|
|
22803
23232
|
function ensureConnections5(config) {
|
|
23233
|
+
if (!config.vercelTraffic) config.vercelTraffic = {};
|
|
23234
|
+
if (!config.vercelTraffic.connections) config.vercelTraffic.connections = [];
|
|
23235
|
+
return config.vercelTraffic.connections;
|
|
23236
|
+
}
|
|
23237
|
+
function getVercelTrafficConnection(config, projectName) {
|
|
23238
|
+
return (config.vercelTraffic?.connections ?? []).find((c) => c.projectName === projectName);
|
|
23239
|
+
}
|
|
23240
|
+
function upsertVercelTrafficConnection(config, connection) {
|
|
23241
|
+
const connections = ensureConnections5(config);
|
|
23242
|
+
const index = connections.findIndex((c) => c.projectName === connection.projectName);
|
|
23243
|
+
if (index === -1) {
|
|
23244
|
+
connections.push(connection);
|
|
23245
|
+
return connection;
|
|
23246
|
+
}
|
|
23247
|
+
connections[index] = connection;
|
|
23248
|
+
return connection;
|
|
23249
|
+
}
|
|
23250
|
+
function removeVercelTrafficConnection(config, projectName) {
|
|
23251
|
+
const connections = config.vercelTraffic?.connections;
|
|
23252
|
+
if (!connections?.length) return false;
|
|
23253
|
+
const next = connections.filter((c) => c.projectName !== projectName);
|
|
23254
|
+
if (next.length === connections.length) return false;
|
|
23255
|
+
if (!config.vercelTraffic) return false;
|
|
23256
|
+
config.vercelTraffic.connections = next;
|
|
23257
|
+
if (next.length === 0) {
|
|
23258
|
+
delete config.vercelTraffic;
|
|
23259
|
+
}
|
|
23260
|
+
return true;
|
|
23261
|
+
}
|
|
23262
|
+
|
|
23263
|
+
// src/wordpress-config.ts
|
|
23264
|
+
function ensureConnections6(config) {
|
|
22804
23265
|
if (!config.wordpress) config.wordpress = {};
|
|
22805
23266
|
if (!config.wordpress.connections) config.wordpress.connections = [];
|
|
22806
23267
|
return config.wordpress.connections;
|
|
@@ -22817,7 +23278,7 @@ function getWordpressConnection(config, projectName) {
|
|
|
22817
23278
|
return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
|
|
22818
23279
|
}
|
|
22819
23280
|
function upsertWordpressConnection(config, connection) {
|
|
22820
|
-
const connections =
|
|
23281
|
+
const connections = ensureConnections6(config);
|
|
22821
23282
|
const normalized = normalizeConnection(connection);
|
|
22822
23283
|
const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
|
|
22823
23284
|
if (index === -1) {
|
|
@@ -27530,6 +27991,21 @@ async function createServer(opts) {
|
|
|
27530
27991
|
return removed;
|
|
27531
27992
|
}
|
|
27532
27993
|
};
|
|
27994
|
+
const vercelTrafficCredentialStore = {
|
|
27995
|
+
getConnection: (projectName) => {
|
|
27996
|
+
return getVercelTrafficConnection(opts.config, projectName);
|
|
27997
|
+
},
|
|
27998
|
+
upsertConnection: (record) => {
|
|
27999
|
+
const updated = upsertVercelTrafficConnection(opts.config, record);
|
|
28000
|
+
saveConfigPatch(opts.config);
|
|
28001
|
+
return updated;
|
|
28002
|
+
},
|
|
28003
|
+
deleteConnection: (projectName) => {
|
|
28004
|
+
const removed = removeVercelTrafficConnection(opts.config, projectName);
|
|
28005
|
+
if (removed) saveConfigPatch(opts.config);
|
|
28006
|
+
return removed;
|
|
28007
|
+
}
|
|
28008
|
+
};
|
|
27533
28009
|
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto34.randomBytes(32).toString("hex");
|
|
27534
28010
|
const googleConnectionStore = {
|
|
27535
28011
|
listConnections: (domain) => listGoogleConnections(opts.config, domain),
|
|
@@ -27889,6 +28365,7 @@ async function createServer(opts) {
|
|
|
27889
28365
|
ga4CredentialStore,
|
|
27890
28366
|
cloudRunCredentialStore,
|
|
27891
28367
|
wordpressTrafficCredentialStore,
|
|
28368
|
+
vercelTrafficCredentialStore,
|
|
27892
28369
|
onTrafficSynced: (event) => {
|
|
27893
28370
|
trackEvent("traffic.synced", {
|
|
27894
28371
|
status: event.status,
|