@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/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-C4UBTDDS.js"></script>
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>
@@ -2705,6 +2705,7 @@ export {
2705
2705
  VerificationStatuses,
2706
2706
  trafficConnectCloudRunRequestSchema,
2707
2707
  trafficConnectWordpressRequestSchema,
2708
+ trafficConnectVercelRequestSchema,
2708
2709
  trafficEventKindSchema,
2709
2710
  TrafficEventKinds,
2710
2711
  discoveryBucketSchema,
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  loadConfigRaw,
7
7
  saveConfigPatch
8
- } from "./chunk-5STLZRGB.js";
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-U3YKRV47.js";
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-HTNC6AWN.js";
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 DEFAULT_MAX_PAGES3 = 5;
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 ?? DEFAULT_MAX_PAGES3;
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 wordpress are supported in v1.`
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 wordpress are supported in v1.`
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/wordpress-config.ts
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 = ensureConnections5(config);
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,
@@ -8,7 +8,7 @@ import {
8
8
  categoryLabel,
9
9
  determineAnswerMentioned,
10
10
  normalizeProjectDomain
11
- } from "./chunk-HTNC6AWN.js";
11
+ } from "./chunk-5M4PP6P4.js";
12
12
 
13
13
  // src/intelligence-service.ts
14
14
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";