@ainyc/canonry 4.30.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.
Files changed (26) hide show
  1. package/README.md +15 -13
  2. package/assets/agent-workspace/skills/aero/SKILL.md +2 -2
  3. package/assets/agent-workspace/skills/aero/references/aeo-discovery.md +26 -17
  4. package/assets/agent-workspace/skills/aero/references/memory-patterns.md +9 -9
  5. package/assets/agent-workspace/skills/aero/references/orchestration.md +6 -6
  6. package/assets/agent-workspace/skills/aero/references/reporting.md +3 -3
  7. package/assets/agent-workspace/skills/canonry/SKILL.md +5 -3
  8. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +9 -9
  9. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +203 -200
  10. package/assets/agent-workspace/skills/canonry/references/indexing.md +35 -35
  11. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +18 -18
  12. package/assets/agent-workspace/skills/canonry/references/wordpress-integration.md +11 -11
  13. package/assets/assets/{index-BnALDZI7.css → index-CNKAwZMB.css} +1 -1
  14. package/assets/assets/index-CUMjedc6.js +302 -0
  15. package/assets/index.html +2 -2
  16. package/dist/{chunk-7UO3EGDB.js → chunk-5M4PP6P4.js} +25 -2
  17. package/dist/{chunk-PTFVEYUX.js → chunk-7I65IXVU.js} +617 -23
  18. package/dist/{chunk-4EDC2P3J.js → chunk-LUAJVZVZ.js} +1 -1
  19. package/dist/{chunk-NIAAHWRF.js → chunk-LVX5TOYA.js} +28 -3
  20. package/dist/cli.js +166 -15
  21. package/dist/index.d.ts +20 -0
  22. package/dist/index.js +4 -4
  23. package/dist/{intelligence-service-ASXADXLF.js → intelligence-service-RSRWDBHS.js} +2 -2
  24. package/dist/mcp.js +2 -2
  25. package/package.json +8 -7
  26. package/assets/assets/index-BYiZYtd9.js +0 -302
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  loadConfigRaw,
7
7
  saveConfigPatch
8
- } from "./chunk-NIAAHWRF.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-4EDC2P3J.js";
73
+ } from "./chunk-LUAJVZVZ.js";
74
74
  import {
75
75
  AGENT_MEMORY_VALUE_MAX_BYTES,
76
76
  AGENT_PROVIDER_IDS,
@@ -82,9 +82,11 @@ import {
82
82
  CheckStatuses,
83
83
  CitationStates,
84
84
  DEFAULT_DISCOVERY_PROMOTE_BUCKETS,
85
+ DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES,
85
86
  DISCOVERY_PROMOTE_COMPETITOR_CAP,
86
87
  DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS,
87
88
  DiscoveryBuckets,
89
+ DiscoveryCompetitorTypes,
88
90
  DiscoverySessionStatuses,
89
91
  MemorySources,
90
92
  RunKinds,
@@ -169,13 +171,14 @@ import {
169
171
  serializeRunError,
170
172
  snapshotRequestSchema,
171
173
  summarizeCheckResults,
174
+ trafficConnectVercelRequestSchema,
172
175
  trafficConnectWordpressRequestSchema,
173
176
  unsupportedKind,
174
177
  validationError,
175
178
  visibilityStateFromAnswerMentioned,
176
179
  windowCutoff,
177
180
  wordpressEnvSchema
178
- } from "./chunk-7UO3EGDB.js";
181
+ } from "./chunk-5M4PP6P4.js";
179
182
 
180
183
  // src/telemetry.ts
181
184
  import crypto from "crypto";
@@ -10232,6 +10235,38 @@ var routeCatalog = [
10232
10235
  502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
10233
10236
  }
10234
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
+ },
10235
10270
  {
10236
10271
  method: "post",
10237
10272
  path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
@@ -10425,7 +10460,7 @@ var routeCatalog = [
10425
10460
  method: "post",
10426
10461
  path: "/api/v1/projects/{name}/discover/sessions/{id}/promote",
10427
10462
  summary: "Promote a discovery session into the tracked basket",
10428
- description: 'Adopts a completed session\'s bucketed queries into the project\'s tracked basket, tagged with `provenance="discovery:<sessionId>"`. By default, only `cited` and `aspirational` queries are promoted; include `wasted-surface` explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains are also merged by default. Add-only and idempotent: queries/domains already tracked are returned under `skipped` rather than inserted twice. Only sessions with `status: "completed"` can be promoted.',
10463
+ description: 'Adopts a completed session\'s bucketed queries into the project\'s tracked basket, tagged with `provenance="discovery:<sessionId>"`. By default, only `cited` and `aspirational` queries are promoted; include `wasted-surface` explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains classified as `direct-competitor` are also merged by default \u2014 pass `competitorTypes` to adopt other classified types or to recover legacy `unknown` entries. Add-only and idempotent: queries/domains already tracked are returned under `skipped` rather than inserted twice. Only sessions with `status: "completed"` can be promoted.',
10429
10464
  tags: ["discovery"],
10430
10465
  parameters: [
10431
10466
  nameParameter,
@@ -10446,6 +10481,14 @@ var routeCatalog = [
10446
10481
  includeCompetitors: {
10447
10482
  type: "boolean",
10448
10483
  description: "Whether to also merge recurring discovered competitor domains. Defaults to true."
10484
+ },
10485
+ competitorTypes: {
10486
+ type: "array",
10487
+ items: {
10488
+ type: "string",
10489
+ enum: ["direct-competitor", "ota-aggregator", "editorial-media", "other", "unknown"]
10490
+ },
10491
+ description: "Which classified competitor types to merge. Omitted means direct-competitor only. Ignored when includeCompetitors is false."
10449
10492
  }
10450
10493
  }
10451
10494
  }
@@ -17597,13 +17640,194 @@ async function listWordpressTrafficEvents(options) {
17597
17640
  };
17598
17641
  }
17599
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
+
17600
17823
  // ../api-routes/src/traffic.ts
17601
17824
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
17602
17825
  var DEFAULT_PAGE_SIZE3 = 1e3;
17603
- var DEFAULT_MAX_PAGES3 = 5;
17826
+ var DEFAULT_MAX_PAGES4 = 5;
17604
17827
  var DEFAULT_SAMPLE_LIMIT2 = 100;
17605
17828
  var DEFAULT_WP_PAGE_SIZE = 500;
17606
17829
  var DEFAULT_WP_MAX_PAGES = 20;
17830
+ var DEFAULT_VERCEL_MAX_PAGES = 50;
17607
17831
  var MAX_TRACKED_EVENT_IDS = 1e3;
17608
17832
  var DEFAULT_BACKFILL_DAYS = 30;
17609
17833
  var MAX_BACKFILL_DAYS = 30;
@@ -17784,9 +18008,11 @@ async function trafficRoutes(app, opts) {
17784
18008
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
17785
18009
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
17786
18010
  const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
18011
+ const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
18012
+ const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
17787
18013
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
17788
18014
  const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
17789
- const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES3;
18015
+ const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES4;
17790
18016
  const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
17791
18017
  app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
17792
18018
  const project = resolveProject(app.db, request.params.name);
@@ -17948,15 +18174,98 @@ async function trafficRoutes(app, opts) {
17948
18174
  });
17949
18175
  return rowToDto(sourceRow);
17950
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
+ });
17951
18260
  app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
17952
18261
  const project = resolveProject(app.db, request.params.name);
17953
18262
  const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
17954
18263
  if (!sourceRow || sourceRow.projectId !== project.id) {
17955
18264
  throw notFound("Traffic source", request.params.id);
17956
18265
  }
17957
- 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) {
17958
18267
  throw validationError(
17959
- `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.`
17960
18269
  );
17961
18270
  }
17962
18271
  const windowEnd = /* @__PURE__ */ new Date();
@@ -18046,7 +18355,7 @@ async function trafficRoutes(app, opts) {
18046
18355
  markFailed(msg, "PROVIDER_PULL");
18047
18356
  throw providerError(`Cloud Run pull failed: ${msg}`);
18048
18357
  }
18049
- } else {
18358
+ } else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
18050
18359
  auditAction = "traffic.wordpress.synced";
18051
18360
  const credentialStore = opts.wordpressTrafficCredentialStore;
18052
18361
  if (!credentialStore) {
@@ -18087,6 +18396,53 @@ async function trafficRoutes(app, opts) {
18087
18396
  markFailed(msg, "PROVIDER_PULL");
18088
18397
  throw providerError(`WordPress pull failed: ${msg}`);
18089
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;
18090
18446
  }
18091
18447
  let crawlerBucketRows = 0;
18092
18448
  let aiReferralBucketRows = 0;
@@ -18271,9 +18627,9 @@ async function trafficRoutes(app, opts) {
18271
18627
  if (!sourceRow || sourceRow.projectId !== project.id) {
18272
18628
  throw notFound("Traffic source", request.params.id);
18273
18629
  }
18274
- 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) {
18275
18631
  throw validationError(
18276
- `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.`
18277
18633
  );
18278
18634
  }
18279
18635
  const requestedDays = request.body?.days ?? DEFAULT_BACKFILL_DAYS;
@@ -18321,7 +18677,7 @@ async function trafficRoutes(app, opts) {
18321
18677
  });
18322
18678
  return page.events;
18323
18679
  };
18324
- } else {
18680
+ } else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
18325
18681
  const credentialStore = opts.wordpressTrafficCredentialStore;
18326
18682
  if (!credentialStore) {
18327
18683
  throw validationError("WordPress traffic credential storage is not configured for this deployment");
@@ -18360,6 +18716,39 @@ async function trafficRoutes(app, opts) {
18360
18716
  }
18361
18717
  return collected;
18362
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
+ };
18363
18752
  }
18364
18753
  const startedAt = windowEnd.toISOString();
18365
18754
  const runId = crypto20.randomUUID();
@@ -19725,7 +20114,7 @@ async function discoveryRoutes(app, opts) {
19725
20114
  else if (bucket === DiscoveryBuckets.aspirational) aspirational.add(probe.query);
19726
20115
  else if (bucket === DiscoveryBuckets["wasted-surface"]) wasted.add(probe.query);
19727
20116
  }
19728
- const competitorMap = parseJsonColumn(session.competitorMap, []);
20117
+ const competitorMap = parseCompetitorMap(session.competitorMap);
19729
20118
  const newCompetitors = selectEligibleCompetitors(competitorMap).filter((entry) => !seenCompetitors.has(entry.domain.toLowerCase()));
19730
20119
  return reply.send({
19731
20120
  sessionId: session.id,
@@ -19764,6 +20153,7 @@ async function discoveryRoutes(app, opts) {
19764
20153
  const buckets = parsed.data.buckets ?? DEFAULT_DISCOVERY_PROMOTE_BUCKETS;
19765
20154
  const bucketSet = new Set(buckets);
19766
20155
  const includeCompetitors = parsed.data.includeCompetitors ?? true;
20156
+ const competitorTypes = parsed.data.competitorTypes ?? DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES;
19767
20157
  const probeRows = app.db.select().from(discoveryProbes).where(eq25(discoveryProbes.sessionId, session.id)).all();
19768
20158
  const candidateQueries = /* @__PURE__ */ new Set();
19769
20159
  for (const probe of probeRows) {
@@ -19790,8 +20180,8 @@ async function discoveryRoutes(app, opts) {
19790
20180
  const existingCompetitors = new Set(
19791
20181
  app.db.select({ domain: competitors.domain }).from(competitors).where(eq25(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
19792
20182
  );
19793
- const competitorMap = parseJsonColumn(session.competitorMap, []);
19794
- for (const entry of selectEligibleCompetitors(competitorMap)) {
20183
+ const competitorMap = parseCompetitorMap(session.competitorMap);
20184
+ for (const entry of selectEligibleCompetitors(competitorMap, competitorTypes)) {
19795
20185
  const key = entry.domain.toLowerCase();
19796
20186
  if (existingCompetitors.has(key)) {
19797
20187
  skippedCompetitors.push(entry.domain);
@@ -19856,7 +20246,7 @@ function serializeSession(row) {
19856
20246
  citedCount: row.citedCount ?? null,
19857
20247
  aspirationalCount: row.aspirationalCount ?? null,
19858
20248
  wastedCount: row.wastedCount ?? null,
19859
- competitorMap: parseJsonColumn(row.competitorMap, []),
20249
+ competitorMap: parseCompetitorMap(row.competitorMap),
19860
20250
  error: row.error ?? null,
19861
20251
  startedAt: row.startedAt ?? null,
19862
20252
  finishedAt: row.finishedAt ?? null,
@@ -19877,8 +20267,20 @@ function serializeProbe(row) {
19877
20267
  createdAt: row.createdAt
19878
20268
  };
19879
20269
  }
19880
- function selectEligibleCompetitors(competitorMap) {
19881
- return competitorMap.filter((entry) => entry.hits >= DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain)).slice(0, DISCOVERY_PROMOTE_COMPETITOR_CAP);
20270
+ function parseCompetitorMap(json) {
20271
+ const raw = parseJsonColumn(
20272
+ json,
20273
+ []
20274
+ );
20275
+ return raw.map((entry) => ({
20276
+ domain: entry.domain,
20277
+ hits: entry.hits,
20278
+ competitorType: entry.competitorType ?? DiscoveryCompetitorTypes.unknown
20279
+ }));
20280
+ }
20281
+ function selectEligibleCompetitors(competitorMap, competitorTypes) {
20282
+ const typeFilter = competitorTypes ? new Set(competitorTypes) : null;
20283
+ return competitorMap.filter((entry) => entry.hits >= DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS).filter((entry) => !typeFilter || typeFilter.has(entry.competitorType)).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain)).slice(0, DISCOVERY_PROMOTE_COMPETITOR_CAP);
19882
20284
  }
19883
20285
 
19884
20286
  // ../api-routes/src/discovery/orchestrate.ts
@@ -19895,7 +20297,7 @@ function classifyProbeBucket(input) {
19895
20297
  if (competitorHit) return DiscoveryBuckets["wasted-surface"];
19896
20298
  return DiscoveryBuckets.aspirational;
19897
20299
  }
19898
- function buildCompetitorMap(probes, project) {
20300
+ function buildCompetitorMap(probes, project, classification = {}) {
19899
20301
  const canonical = new Set(project.canonicalDomains.map((d) => d.toLowerCase()));
19900
20302
  const counts = /* @__PURE__ */ new Map();
19901
20303
  for (const probe of probes) {
@@ -19908,7 +20310,19 @@ function buildCompetitorMap(probes, project) {
19908
20310
  counts.set(domain, (counts.get(domain) ?? 0) + 1);
19909
20311
  }
19910
20312
  }
19911
- return Array.from(counts.entries()).map(([domain, hits]) => ({ domain, hits })).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain));
20313
+ return Array.from(counts.entries()).map(([domain, hits]) => ({
20314
+ domain,
20315
+ hits,
20316
+ competitorType: classification[domain] ?? DiscoveryCompetitorTypes.unknown
20317
+ })).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain));
20318
+ }
20319
+ async function classifyCompetitorDomains(deps, project, icpDescription, domains) {
20320
+ if (domains.length === 0) return {};
20321
+ try {
20322
+ return await deps.classifyDomains({ project, icpDescription, domains });
20323
+ } catch {
20324
+ return {};
20325
+ }
19912
20326
  }
19913
20327
  async function pickCanonicals(candidates, deps, dedupThreshold) {
19914
20328
  if (candidates.length === 0) return [];
@@ -19969,7 +20383,14 @@ async function executeDiscovery(opts) {
19969
20383
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
19970
20384
  }).run();
19971
20385
  }
19972
- const competitorMap = buildCompetitorMap(probeRows, opts.project);
20386
+ const domains = buildCompetitorMap(probeRows, opts.project).map((entry) => entry.domain);
20387
+ const classification = await classifyCompetitorDomains(
20388
+ opts.deps,
20389
+ opts.project,
20390
+ opts.icpDescription,
20391
+ domains
20392
+ );
20393
+ const competitorMap = buildCompetitorMap(probeRows, opts.project, classification);
19973
20394
  opts.db.update(discoverySessions).set({
19974
20395
  status: DiscoverySessionStatuses.completed,
19975
20396
  probeCount: probedCanonicals.length,
@@ -20127,6 +20548,8 @@ async function apiRoutes(app, opts) {
20127
20548
  resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
20128
20549
  wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
20129
20550
  pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
20551
+ vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
20552
+ pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
20130
20553
  onTrafficSynced: opts.onTrafficSynced
20131
20554
  });
20132
20555
  await api.register(backlinksRoutes, {
@@ -20240,6 +20663,54 @@ function buildTrafficSourceValidators(opts) {
20240
20663
  validateScopes: () => null
20241
20664
  };
20242
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
+ }
20243
20714
  return Object.keys(validators).length > 0 ? validators : void 0;
20244
20715
  }
20245
20716
 
@@ -22757,8 +23228,40 @@ function removeWordpressTrafficConnection(config, projectName) {
22757
23228
  return true;
22758
23229
  }
22759
23230
 
22760
- // src/wordpress-config.ts
23231
+ // src/vercel-traffic-config.ts
22761
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) {
22762
23265
  if (!config.wordpress) config.wordpress = {};
22763
23266
  if (!config.wordpress.connections) config.wordpress.connections = [];
22764
23267
  return config.wordpress.connections;
@@ -22775,7 +23278,7 @@ function getWordpressConnection(config, projectName) {
22775
23278
  return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
22776
23279
  }
22777
23280
  function upsertWordpressConnection(config, connection) {
22778
- const connections = ensureConnections5(config);
23281
+ const connections = ensureConnections6(config);
22779
23282
  const normalized = normalizeConnection(connection);
22780
23283
  const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
22781
23284
  if (index === -1) {
@@ -24560,9 +25063,84 @@ function buildDefaultDeps(registry) {
24560
25063
  citedDomains: normalized.citedDomains,
24561
25064
  rawResponse: raw.rawResponse
24562
25065
  };
25066
+ },
25067
+ async classifyDomains(input) {
25068
+ const prompt = buildClassificationPrompt(input);
25069
+ const text = await adapter.generateText(prompt, cfg);
25070
+ return parseClassificationResponse(text, input.domains);
24563
25071
  }
24564
25072
  };
24565
25073
  }
25074
+ var CLASSIFICATION_CATEGORIES = [
25075
+ DiscoveryCompetitorTypes["direct-competitor"],
25076
+ DiscoveryCompetitorTypes["ota-aggregator"],
25077
+ DiscoveryCompetitorTypes["editorial-media"],
25078
+ DiscoveryCompetitorTypes.other
25079
+ ];
25080
+ var CLASSIFICATION_CATEGORY_MATCHERS = CLASSIFICATION_CATEGORIES.map((category) => ({
25081
+ category,
25082
+ pattern: new RegExp(`(?<![a-z0-9])${category}(?![a-z0-9])`)
25083
+ }));
25084
+ function buildClassificationPrompt(input) {
25085
+ const tracked = input.project.competitorDomains.length > 0 ? input.project.competitorDomains.join(", ") : "none";
25086
+ return [
25087
+ "You are an AEO (Answer Engine Optimization) analyst classifying the domains that AI answer engines cited for a customer's tracked queries.",
25088
+ "",
25089
+ `Customer: ${input.project.name} (own domains: ${input.project.canonicalDomains.join(", ")})`,
25090
+ `ICP: ${input.icpDescription}`,
25091
+ `Already-tracked competitors: ${tracked}`,
25092
+ "",
25093
+ "Classify EACH domain below into exactly one category:",
25094
+ " - direct-competitor: a business competing directly with the customer for the same customers (another company in the same category). Every already-tracked competitor above is a direct-competitor.",
25095
+ " - ota-aggregator: online travel agencies, marketplaces, directories, booking platforms, or review aggregators that list many businesses (e.g. expedia.com, booking.com, tripadvisor.com, yelp.com, g2.com).",
25096
+ ' - editorial-media: news sites, magazines, blogs, or "best of" listicle / round-up articles (e.g. timeout.com, nytimes.com, personal blogs).',
25097
+ " - other: anything else \u2014 government sites, social media, the customer itself, or domains unrelated to the competitive space.",
25098
+ "",
25099
+ "Domains:",
25100
+ ...input.domains,
25101
+ "",
25102
+ "Return ONE line per domain in EXACTLY this format:",
25103
+ "<domain> => <category>",
25104
+ "",
25105
+ "Plain text only. No numbering, bullets, commentary, or markdown."
25106
+ ].join("\n");
25107
+ }
25108
+ function parseClassificationResponse(text, domains) {
25109
+ const lines = text.split("\n").map((l) => l.trim().toLowerCase()).filter(Boolean);
25110
+ const result = {};
25111
+ for (const domain of domains) {
25112
+ const key = domain.toLowerCase();
25113
+ const line = lines.find((l) => startsWithDomainToken(l, key)) ?? lines.find((l) => containsDomainToken(l, key));
25114
+ if (!line) continue;
25115
+ const category = extractClassificationCategory(line);
25116
+ if (category) result[domain] = category;
25117
+ }
25118
+ return result;
25119
+ }
25120
+ function isDomainChar(ch) {
25121
+ return /[a-z0-9.-]/.test(ch);
25122
+ }
25123
+ function startsWithDomainToken(line, domain) {
25124
+ return line.startsWith(domain) && !isDomainChar(line[domain.length] ?? "");
25125
+ }
25126
+ function containsDomainToken(line, domain) {
25127
+ let idx = line.indexOf(domain);
25128
+ while (idx !== -1) {
25129
+ const before = line[idx - 1] ?? "";
25130
+ const after = line[idx + domain.length] ?? "";
25131
+ if (!isDomainChar(before) && !isDomainChar(after)) return true;
25132
+ idx = line.indexOf(domain, idx + 1);
25133
+ }
25134
+ return false;
25135
+ }
25136
+ function extractClassificationCategory(line) {
25137
+ const arrowIdx = line.indexOf("=>");
25138
+ const haystack = arrowIdx >= 0 ? line.slice(arrowIdx + 2) : line;
25139
+ for (const { category, pattern } of CLASSIFICATION_CATEGORY_MATCHERS) {
25140
+ if (pattern.test(haystack)) return category;
25141
+ }
25142
+ return null;
25143
+ }
24566
25144
  function buildSeedPrompt(input) {
24567
25145
  return [
24568
25146
  "You are an AEO (Answer Engine Optimization) analyst expanding a tracked-query basket for a customer.",
@@ -27413,6 +27991,21 @@ async function createServer(opts) {
27413
27991
  return removed;
27414
27992
  }
27415
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
+ };
27416
28009
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto34.randomBytes(32).toString("hex");
27417
28010
  const googleConnectionStore = {
27418
28011
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -27772,6 +28365,7 @@ async function createServer(opts) {
27772
28365
  ga4CredentialStore,
27773
28366
  cloudRunCredentialStore,
27774
28367
  wordpressTrafficCredentialStore,
28368
+ vercelTrafficCredentialStore,
27775
28369
  onTrafficSynced: (event) => {
27776
28370
  trackEvent("traffic.synced", {
27777
28371
  status: event.status,