@ainyc/canonry 4.31.0 → 4.33.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.
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  loadConfigRaw,
7
7
  saveConfigPatch
8
- } from "./chunk-5STLZRGB.js";
8
+ } from "./chunk-5EBN7736.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-BJXHETQW.js";
74
74
  import {
75
75
  AGENT_MEMORY_VALUE_MAX_BYTES,
76
76
  AGENT_PROVIDER_IDS,
@@ -162,6 +162,7 @@ import {
162
162
  reportHorizonLabel,
163
163
  reportSeverityLabel,
164
164
  resolveConfigSpecQueries,
165
+ resolveLocations,
165
166
  resolveSnapshotRequestQueries,
166
167
  runInProgress,
167
168
  runNotCancellable,
@@ -171,13 +172,14 @@ import {
171
172
  serializeRunError,
172
173
  snapshotRequestSchema,
173
174
  summarizeCheckResults,
175
+ trafficConnectVercelRequestSchema,
174
176
  trafficConnectWordpressRequestSchema,
175
177
  unsupportedKind,
176
178
  validationError,
177
179
  visibilityStateFromAnswerMentioned,
178
180
  windowCutoff,
179
181
  wordpressEnvSchema
180
- } from "./chunk-HTNC6AWN.js";
182
+ } from "./chunk-XW3F5EEW.js";
181
183
 
182
184
  // src/telemetry.ts
183
185
  import crypto from "crypto";
@@ -10234,6 +10236,38 @@ var routeCatalog = [
10234
10236
  502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
10235
10237
  }
10236
10238
  },
10239
+ {
10240
+ method: "post",
10241
+ path: "/api/v1/projects/{name}/traffic/connect/vercel",
10242
+ summary: "Connect a Vercel traffic source",
10243
+ 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.",
10244
+ tags: ["traffic"],
10245
+ parameters: [nameParameter],
10246
+ requestBody: {
10247
+ required: true,
10248
+ content: {
10249
+ "application/json": {
10250
+ schema: {
10251
+ type: "object",
10252
+ required: ["projectId", "teamId", "token"],
10253
+ properties: {
10254
+ projectId: { ...stringSchema, description: "Vercel project id (e.g. `prj_...`) \u2014 from the Vercel dashboard or `.vercel/project.json`." },
10255
+ teamId: { ...stringSchema, description: "Vercel team / owner id (e.g. `team_...`)." },
10256
+ token: { ...stringSchema, description: "Vercel API token (personal access token). Stored in `~/.canonry/config.yaml`, never the DB or response." },
10257
+ environment: { type: "string", enum: ["production", "preview"], description: "Which deployment environment's request logs to pull. Default: `production`." },
10258
+ displayName: stringSchema
10259
+ }
10260
+ }
10261
+ }
10262
+ }
10263
+ },
10264
+ responses: {
10265
+ 200: { description: "Traffic source DTO returned." },
10266
+ 400: { description: "Invalid Vercel connection request." },
10267
+ 404: { description: "Project not found." },
10268
+ 502: { description: "Vercel request-logs endpoint probe failed (bad token, wrong project / team id, unreachable host, etc.)." }
10269
+ }
10270
+ },
10237
10271
  {
10238
10272
  method: "post",
10239
10273
  path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
@@ -10366,7 +10400,12 @@ var routeCatalog = [
10366
10400
  properties: {
10367
10401
  icpDescription: { type: "string", description: "Free-text ICP. Required if the project does not have spec.icpDescription stored." },
10368
10402
  dedupThreshold: { type: "number", description: "Cosine similarity threshold for clustering. Defaults to 0.85." },
10369
- maxProbes: { type: "integer", description: "Max canonical queries to probe in this session. Default 100, hard cap 500." }
10403
+ maxProbes: { type: "integer", description: "Max canonical queries to probe in this session. Default 100, hard cap 500." },
10404
+ locations: {
10405
+ type: "array",
10406
+ items: { type: "string" },
10407
+ description: "Optional override of the project location labels used to geo-constrain seed generation. Each label must match a configured project location; an unknown label is a 400. Omit to use every project location."
10408
+ }
10370
10409
  }
10371
10410
  }
10372
10411
  }
@@ -17607,13 +17646,194 @@ async function listWordpressTrafficEvents(options) {
17607
17646
  };
17608
17647
  }
17609
17648
 
17649
+ // ../integration-vercel/src/normalize.ts
17650
+ function numberOrNull2(value) {
17651
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
17652
+ return value;
17653
+ }
17654
+ function resolveStatus(row) {
17655
+ if (typeof row.statusCode === "number" && row.statusCode >= 100) {
17656
+ return row.statusCode;
17657
+ }
17658
+ const events = row.events ?? [];
17659
+ for (let i = events.length - 1; i >= 0; i -= 1) {
17660
+ const status = events[i]?.httpStatus;
17661
+ if (typeof status === "number" && status >= 100) return status;
17662
+ }
17663
+ return null;
17664
+ }
17665
+ function serializeSearchParams(params) {
17666
+ if (!params) return null;
17667
+ const entries = Object.entries(params).filter(
17668
+ (entry) => typeof entry[1] === "string"
17669
+ );
17670
+ if (entries.length === 0) return null;
17671
+ return new URLSearchParams(entries).toString();
17672
+ }
17673
+ function emptyToNull(value) {
17674
+ if (typeof value !== "string" || value.trim() === "") return null;
17675
+ return value;
17676
+ }
17677
+ function stringLabels(input) {
17678
+ return Object.fromEntries(
17679
+ Object.entries(input).filter(
17680
+ (entry) => typeof entry[1] === "string" && entry[1] !== ""
17681
+ )
17682
+ );
17683
+ }
17684
+ function normalizeVercelLogRow(row) {
17685
+ const path15 = row.requestPath;
17686
+ if (!path15) return null;
17687
+ const observedAt = row.timestamp;
17688
+ if (!observedAt) return null;
17689
+ const requestId = row.requestId;
17690
+ if (!requestId) return null;
17691
+ const host = emptyToNull(row.domain);
17692
+ const queryString = serializeSearchParams(row.requestSearchParams);
17693
+ const requestUrl = host ? `https://${host}${path15}${queryString ? `?${queryString}` : ""}` : null;
17694
+ return {
17695
+ sourceType: TrafficSourceTypes.vercel,
17696
+ evidenceKind: TrafficEvidenceKinds["raw-request"],
17697
+ confidence: TrafficEventConfidences.observed,
17698
+ eventId: `vercel:${observedAt}:${requestId}`,
17699
+ observedAt,
17700
+ method: row.requestMethod ?? null,
17701
+ requestUrl,
17702
+ host,
17703
+ path: path15,
17704
+ queryString,
17705
+ status: resolveStatus(row),
17706
+ userAgent: emptyToNull(row.clientUserAgent),
17707
+ // The request-logs endpoint does not expose a client IP; UA-only matches
17708
+ // stay `claimed_unverified` in the classifier.
17709
+ remoteIp: null,
17710
+ referer: emptyToNull(row.requestReferer),
17711
+ latencyMs: numberOrNull2(row.requestDurationMs),
17712
+ requestSizeBytes: null,
17713
+ responseSizeBytes: null,
17714
+ providerResource: {
17715
+ type: "vercel_deployment",
17716
+ labels: stringLabels({
17717
+ deploymentId: row.deploymentId,
17718
+ environment: row.environment,
17719
+ region: row.clientRegion
17720
+ })
17721
+ },
17722
+ providerLabels: stringLabels({
17723
+ branch: row.branch,
17724
+ cache: row.cache
17725
+ })
17726
+ };
17727
+ }
17728
+
17729
+ // ../integration-vercel/src/client.ts
17730
+ var VERCEL_REQUEST_LOGS_URL = "https://vercel.com/api/logs/request-logs";
17731
+ var DEFAULT_ENVIRONMENT = "production";
17732
+ var DEFAULT_MAX_PAGES3 = 1;
17733
+ var DEFAULT_TIMEOUT_MS3 = 3e4;
17734
+ var VercelLogsApiError = class extends Error {
17735
+ constructor(message, status, body) {
17736
+ super(message);
17737
+ this.status = status;
17738
+ this.body = body;
17739
+ this.name = "VercelLogsApiError";
17740
+ }
17741
+ };
17742
+ function trimRequired2(name, value) {
17743
+ const trimmed = value.trim();
17744
+ if (!trimmed) {
17745
+ throw new VercelLogsApiError(`${name} is required`, 400);
17746
+ }
17747
+ return trimmed;
17748
+ }
17749
+ function normalizeMaxPages3(maxPages) {
17750
+ if (maxPages === void 0) return DEFAULT_MAX_PAGES3;
17751
+ if (!Number.isInteger(maxPages) || maxPages < 1) {
17752
+ throw new VercelLogsApiError("maxPages must be a positive integer", 400);
17753
+ }
17754
+ return maxPages;
17755
+ }
17756
+ function toEpochMs(label, value) {
17757
+ const ms = value instanceof Date ? value.getTime() : typeof value === "number" ? value : new Date(value).getTime();
17758
+ if (!Number.isFinite(ms)) {
17759
+ throw new VercelLogsApiError(`${label} must be a valid date`, 400);
17760
+ }
17761
+ return String(Math.trunc(ms));
17762
+ }
17763
+ async function readErrorBody3(response) {
17764
+ const text = await response.text().catch(() => "");
17765
+ if (!text) return void 0;
17766
+ return text.length <= 500 ? text : `${text.slice(0, 500)}... [truncated]`;
17767
+ }
17768
+ async function listVercelTrafficEvents(options) {
17769
+ const token = trimRequired2("token", options.token);
17770
+ const projectId = trimRequired2("projectId", options.projectId);
17771
+ const teamId = trimRequired2("teamId", options.teamId);
17772
+ const environment = options.environment ?? DEFAULT_ENVIRONMENT;
17773
+ const startDate = toEpochMs("startDate", options.startDate);
17774
+ const endDate = toEpochMs("endDate", options.endDate);
17775
+ const maxPages = normalizeMaxPages3(options.maxPages);
17776
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
17777
+ let rawEntryCount = 0;
17778
+ let skippedEntryCount = 0;
17779
+ let hasMore = false;
17780
+ const events = [];
17781
+ for (let page = 0; page < maxPages; page += 1) {
17782
+ const url = new URL(VERCEL_REQUEST_LOGS_URL);
17783
+ url.searchParams.set("projectId", projectId);
17784
+ url.searchParams.set("ownerId", teamId);
17785
+ url.searchParams.set("teamId", teamId);
17786
+ url.searchParams.set("page", String(page));
17787
+ url.searchParams.set("startDate", startDate);
17788
+ url.searchParams.set("endDate", endDate);
17789
+ url.searchParams.set("environment", environment);
17790
+ const response = await fetch(url, {
17791
+ method: "GET",
17792
+ headers: {
17793
+ Authorization: `Bearer ${token}`,
17794
+ Accept: "application/json"
17795
+ },
17796
+ signal: AbortSignal.timeout(timeoutMs)
17797
+ });
17798
+ if (!response.ok) {
17799
+ const body2 = await readErrorBody3(response);
17800
+ throw new VercelLogsApiError(
17801
+ `Vercel request-logs endpoint returned HTTP ${response.status}`,
17802
+ response.status,
17803
+ body2
17804
+ );
17805
+ }
17806
+ const body = await response.json();
17807
+ const rows = body.rows ?? [];
17808
+ rawEntryCount += rows.length;
17809
+ for (const row of rows) {
17810
+ const event = normalizeVercelLogRow(row);
17811
+ if (event) {
17812
+ events.push(event);
17813
+ } else {
17814
+ skippedEntryCount += 1;
17815
+ }
17816
+ }
17817
+ hasMore = Boolean(body.hasMoreRows);
17818
+ if (!hasMore) break;
17819
+ }
17820
+ return {
17821
+ events,
17822
+ rawEntryCount,
17823
+ skippedEntryCount,
17824
+ hasMore,
17825
+ endpoint: VERCEL_REQUEST_LOGS_URL
17826
+ };
17827
+ }
17828
+
17610
17829
  // ../api-routes/src/traffic.ts
17611
17830
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
17612
17831
  var DEFAULT_PAGE_SIZE3 = 1e3;
17613
- var DEFAULT_MAX_PAGES3 = 5;
17832
+ var DEFAULT_MAX_PAGES4 = 5;
17614
17833
  var DEFAULT_SAMPLE_LIMIT2 = 100;
17615
17834
  var DEFAULT_WP_PAGE_SIZE = 500;
17616
17835
  var DEFAULT_WP_MAX_PAGES = 20;
17836
+ var DEFAULT_VERCEL_MAX_PAGES = 50;
17617
17837
  var MAX_TRACKED_EVENT_IDS = 1e3;
17618
17838
  var DEFAULT_BACKFILL_DAYS = 30;
17619
17839
  var MAX_BACKFILL_DAYS = 30;
@@ -17794,9 +18014,11 @@ async function trafficRoutes(app, opts) {
17794
18014
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
17795
18015
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
17796
18016
  const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
18017
+ const pullVercelEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
18018
+ const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
17797
18019
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
17798
18020
  const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
17799
- const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES3;
18021
+ const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES4;
17800
18022
  const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
17801
18023
  app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
17802
18024
  const project = resolveProject(app.db, request.params.name);
@@ -17958,15 +18180,98 @@ async function trafficRoutes(app, opts) {
17958
18180
  });
17959
18181
  return rowToDto(sourceRow);
17960
18182
  });
18183
+ app.post("/projects/:name/traffic/connect/vercel", async (request) => {
18184
+ const project = resolveProject(app.db, request.params.name);
18185
+ if (!opts.vercelTrafficCredentialStore) {
18186
+ throw validationError("Vercel traffic credential storage is not configured for this deployment");
18187
+ }
18188
+ const credentialStore = opts.vercelTrafficCredentialStore;
18189
+ const parsed = trafficConnectVercelRequestSchema.safeParse(request.body ?? {});
18190
+ if (!parsed.success) {
18191
+ throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
18192
+ }
18193
+ const { projectId, teamId, token, displayName } = parsed.data;
18194
+ const environment = parsed.data.environment ?? "production";
18195
+ const probeEnd = Date.now();
18196
+ try {
18197
+ await pullVercelEvents({
18198
+ token,
18199
+ projectId,
18200
+ teamId,
18201
+ environment,
18202
+ startDate: probeEnd - 60 * 6e4,
18203
+ endDate: probeEnd,
18204
+ maxPages: 1
18205
+ });
18206
+ } catch (e) {
18207
+ if (e instanceof VercelLogsApiError) {
18208
+ throw providerError(
18209
+ `Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
18210
+ );
18211
+ }
18212
+ const msg = e instanceof Error ? e.message : String(e);
18213
+ throw providerError(`Vercel traffic probe failed: ${msg}`);
18214
+ }
18215
+ const now = (/* @__PURE__ */ new Date()).toISOString();
18216
+ const existing = credentialStore.getConnection(project.name);
18217
+ credentialStore.upsertConnection({
18218
+ projectName: project.name,
18219
+ projectId,
18220
+ teamId,
18221
+ token,
18222
+ environment,
18223
+ createdAt: existing?.createdAt ?? now,
18224
+ updatedAt: now
18225
+ });
18226
+ const activeSource = app.db.select().from(trafficSources).where(eq23(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.vercel && row.status !== TrafficSourceStatuses.archived);
18227
+ const config = { projectId, teamId, environment };
18228
+ const fallbackName = displayName ?? `Vercel \xB7 ${projectId}`;
18229
+ let sourceRow;
18230
+ if (activeSource) {
18231
+ app.db.update(trafficSources).set({
18232
+ displayName: fallbackName,
18233
+ status: TrafficSourceStatuses.connected,
18234
+ lastError: null,
18235
+ configJson: JSON.stringify(config),
18236
+ updatedAt: now
18237
+ }).where(eq23(trafficSources.id, activeSource.id)).run();
18238
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, activeSource.id)).get();
18239
+ } else {
18240
+ const newId = crypto20.randomUUID();
18241
+ app.db.insert(trafficSources).values({
18242
+ id: newId,
18243
+ projectId: project.id,
18244
+ sourceType: TrafficSourceTypes.vercel,
18245
+ displayName: fallbackName,
18246
+ status: TrafficSourceStatuses.connected,
18247
+ lastSyncedAt: null,
18248
+ lastCursor: null,
18249
+ lastError: null,
18250
+ archivedAt: null,
18251
+ configJson: JSON.stringify(config),
18252
+ createdAt: now,
18253
+ updatedAt: now
18254
+ }).run();
18255
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, newId)).get();
18256
+ }
18257
+ writeAuditLog(app.db, {
18258
+ projectId: project.id,
18259
+ actor: "api",
18260
+ action: "traffic.vercel.connected",
18261
+ entityType: "traffic_source",
18262
+ entityId: sourceRow.id
18263
+ });
18264
+ return rowToDto(sourceRow);
18265
+ });
17961
18266
  app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
17962
18267
  const project = resolveProject(app.db, request.params.name);
17963
18268
  const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
17964
18269
  if (!sourceRow || sourceRow.projectId !== project.id) {
17965
18270
  throw notFound("Traffic source", request.params.id);
17966
18271
  }
17967
- if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
18272
+ if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
17968
18273
  throw validationError(
17969
- `Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and wordpress are supported in v1.`
18274
+ `Sync for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
17970
18275
  );
17971
18276
  }
17972
18277
  const windowEnd = /* @__PURE__ */ new Date();
@@ -18056,7 +18361,7 @@ async function trafficRoutes(app, opts) {
18056
18361
  markFailed(msg, "PROVIDER_PULL");
18057
18362
  throw providerError(`Cloud Run pull failed: ${msg}`);
18058
18363
  }
18059
- } else {
18364
+ } else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
18060
18365
  auditAction = "traffic.wordpress.synced";
18061
18366
  const credentialStore = opts.wordpressTrafficCredentialStore;
18062
18367
  if (!credentialStore) {
@@ -18097,6 +18402,53 @@ async function trafficRoutes(app, opts) {
18097
18402
  markFailed(msg, "PROVIDER_PULL");
18098
18403
  throw providerError(`WordPress pull failed: ${msg}`);
18099
18404
  }
18405
+ } else {
18406
+ auditAction = "traffic.vercel.synced";
18407
+ const credentialStore = opts.vercelTrafficCredentialStore;
18408
+ if (!credentialStore) {
18409
+ app.db.delete(runs).where(eq23(runs.id, runId)).run();
18410
+ throw validationError("Vercel traffic credential storage is not configured for this deployment");
18411
+ }
18412
+ const credential = credentialStore.getConnection(project.name);
18413
+ if (!credential) {
18414
+ app.db.delete(runs).where(eq23(runs.id, runId)).run();
18415
+ throw validationError(
18416
+ `No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
18417
+ );
18418
+ }
18419
+ const config = parseSourceConfig(sourceRow);
18420
+ const vercelProjectId = config.projectId ?? credential.projectId;
18421
+ const vercelTeamId = config.teamId ?? credential.teamId;
18422
+ const vercelEnvironment = config.environment ?? credential.environment;
18423
+ const requestedMinutes = request.body?.sinceMinutes;
18424
+ const windowMinutes = Number.isFinite(requestedMinutes) && requestedMinutes && requestedMinutes > 0 ? Math.floor(requestedMinutes) : syncWindowMinutes;
18425
+ const requestedStartMs = windowEnd.getTime() - windowMinutes * 6e4;
18426
+ const lastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
18427
+ windowStart = new Date(
18428
+ Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs))
18429
+ );
18430
+ let page;
18431
+ try {
18432
+ page = await pullVercelEvents({
18433
+ token: credential.token,
18434
+ projectId: vercelProjectId,
18435
+ teamId: vercelTeamId,
18436
+ environment: vercelEnvironment,
18437
+ startDate: windowStart.getTime(),
18438
+ endDate: windowEnd.getTime(),
18439
+ maxPages: vercelMaxPages
18440
+ });
18441
+ } catch (e) {
18442
+ const msg = e instanceof Error ? e.message : String(e);
18443
+ markFailed(msg, "PROVIDER_PULL");
18444
+ throw providerError(`Vercel pull failed: ${msg}`);
18445
+ }
18446
+ if (page.hasMore) {
18447
+ const msg = `Vercel sync window exceeded the ${vercelMaxPages}-page budget \u2014 narrow the window with --since-minutes or run a backfill`;
18448
+ markFailed(msg, "PROVIDER_PULL");
18449
+ throw providerError(`Vercel pull failed: ${msg}`);
18450
+ }
18451
+ allEvents = page.events;
18100
18452
  }
18101
18453
  let crawlerBucketRows = 0;
18102
18454
  let aiReferralBucketRows = 0;
@@ -18281,9 +18633,9 @@ async function trafficRoutes(app, opts) {
18281
18633
  if (!sourceRow || sourceRow.projectId !== project.id) {
18282
18634
  throw notFound("Traffic source", request.params.id);
18283
18635
  }
18284
- if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress) {
18636
+ if (sourceRow.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow.sourceType !== TrafficSourceTypes.wordpress && sourceRow.sourceType !== TrafficSourceTypes.vercel) {
18285
18637
  throw validationError(
18286
- `Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run and wordpress are supported in v1.`
18638
+ `Backfill for source type "${sourceRow.sourceType}" is not implemented yet \u2014 only cloud-run, wordpress, and vercel are supported in v1.`
18287
18639
  );
18288
18640
  }
18289
18641
  const requestedDays = request.body?.days ?? DEFAULT_BACKFILL_DAYS;
@@ -18331,7 +18683,7 @@ async function trafficRoutes(app, opts) {
18331
18683
  });
18332
18684
  return page.events;
18333
18685
  };
18334
- } else {
18686
+ } else if (sourceRow.sourceType === TrafficSourceTypes.wordpress) {
18335
18687
  const credentialStore = opts.wordpressTrafficCredentialStore;
18336
18688
  if (!credentialStore) {
18337
18689
  throw validationError("WordPress traffic credential storage is not configured for this deployment");
@@ -18370,6 +18722,39 @@ async function trafficRoutes(app, opts) {
18370
18722
  }
18371
18723
  return collected;
18372
18724
  };
18725
+ } else {
18726
+ const credentialStore = opts.vercelTrafficCredentialStore;
18727
+ if (!credentialStore) {
18728
+ throw validationError("Vercel traffic credential storage is not configured for this deployment");
18729
+ }
18730
+ const credential = credentialStore.getConnection(project.name);
18731
+ if (!credential) {
18732
+ throw validationError(
18733
+ `No Vercel credential found for project "${project.name}". Run "canonry traffic connect vercel" first.`
18734
+ );
18735
+ }
18736
+ const config = parseSourceConfig(sourceRow);
18737
+ const vercelProjectId = config.projectId ?? credential.projectId;
18738
+ const vercelTeamId = config.teamId ?? credential.teamId;
18739
+ const vercelEnvironment = config.environment ?? credential.environment;
18740
+ pullErrorPrefix = "Vercel pull failed";
18741
+ pullForBackfill = async () => {
18742
+ const page = await pullVercelEvents({
18743
+ token: credential.token,
18744
+ projectId: vercelProjectId,
18745
+ teamId: vercelTeamId,
18746
+ environment: vercelEnvironment,
18747
+ startDate: windowStart.getTime(),
18748
+ endDate: windowEnd.getTime(),
18749
+ maxPages: BACKFILL_MAX_PAGES
18750
+ });
18751
+ if (page.hasMore) {
18752
+ throw new Error(
18753
+ `backfill window exceeded the ${BACKFILL_MAX_PAGES}-page budget \u2014 narrow the window with --days`
18754
+ );
18755
+ }
18756
+ return page.events;
18757
+ };
18373
18758
  }
18374
18759
  const startedAt = windowEnd.toISOString();
18375
18760
  const runId = crypto20.randomUUID();
@@ -19643,6 +20028,10 @@ async function discoveryRoutes(app, opts) {
19643
20028
  "icpDescription is required. Pass it in the request body or store it on the project (spec.icpDescription)."
19644
20029
  );
19645
20030
  }
20031
+ const locations = resolveLocations(
20032
+ parseJsonColumn(project.locations, []),
20033
+ parsed.data.locations
20034
+ );
19646
20035
  if (!opts.onDiscoveryRunRequested) {
19647
20036
  throw validationError("Discovery is not available on this deployment.", {
19648
20037
  reason: "no-discovery-handler"
@@ -19684,7 +20073,8 @@ async function discoveryRoutes(app, opts) {
19684
20073
  projectId: project.id,
19685
20074
  icpDescription,
19686
20075
  dedupThreshold: parsed.data.dedupThreshold,
19687
- maxProbes: parsed.data.maxProbes
20076
+ maxProbes: parsed.data.maxProbes,
20077
+ locations
19688
20078
  });
19689
20079
  return reply.status(201).send({ runId, sessionId, status: "running" });
19690
20080
  });
@@ -19964,7 +20354,8 @@ async function executeDiscovery(opts) {
19964
20354
  }).where(eq26(discoverySessions.id, opts.sessionId)).run();
19965
20355
  const seedResult = await opts.deps.seed({
19966
20356
  project: opts.project,
19967
- icpDescription: opts.icpDescription
20357
+ icpDescription: opts.icpDescription,
20358
+ locations: opts.locations ?? []
19968
20359
  });
19969
20360
  const rawCandidates = dedupeStrings(seedResult.candidates);
19970
20361
  const seedCountRaw = rawCandidates.length;
@@ -20169,6 +20560,8 @@ async function apiRoutes(app, opts) {
20169
20560
  resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
20170
20561
  wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
20171
20562
  pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
20563
+ vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
20564
+ pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
20172
20565
  onTrafficSynced: opts.onTrafficSynced
20173
20566
  });
20174
20567
  await api.register(backlinksRoutes, {
@@ -20282,6 +20675,54 @@ function buildTrafficSourceValidators(opts) {
20282
20675
  validateScopes: () => null
20283
20676
  };
20284
20677
  }
20678
+ if (opts.vercelTrafficCredentialStore) {
20679
+ const store = opts.vercelTrafficCredentialStore;
20680
+ const pullEvents = opts.pullVercelTrafficEvents ?? listVercelTrafficEvents;
20681
+ validators[TrafficSourceTypes.vercel] = {
20682
+ validateCredentials: async (source) => {
20683
+ const record = store.getConnection(source.projectName);
20684
+ if (!record) {
20685
+ return {
20686
+ status: CheckStatuses.fail,
20687
+ code: "traffic.credentials.missing",
20688
+ summary: `No Vercel credential found in ~/.canonry/config.yaml for project "${source.projectName}".`,
20689
+ remediation: "Re-run `canonry traffic connect vercel <project> --project-id <prj> --team-id <team> --token <token>`."
20690
+ };
20691
+ }
20692
+ try {
20693
+ const probeEnd = Date.now();
20694
+ await pullEvents({
20695
+ token: record.token,
20696
+ projectId: record.projectId,
20697
+ teamId: record.teamId,
20698
+ environment: record.environment,
20699
+ startDate: probeEnd - 60 * 6e4,
20700
+ endDate: probeEnd,
20701
+ maxPages: 1
20702
+ });
20703
+ return {
20704
+ status: CheckStatuses.ok,
20705
+ code: "traffic.credentials.resolved",
20706
+ summary: `Vercel request-logs responds for "${source.displayName}" (project ${record.projectId}).`
20707
+ };
20708
+ } catch (e) {
20709
+ const httpStatus = e instanceof VercelLogsApiError ? e.status : null;
20710
+ const msg = e instanceof Error ? e.message : String(e);
20711
+ return {
20712
+ status: CheckStatuses.fail,
20713
+ code: httpStatus === 401 || httpStatus === 403 ? "traffic.credentials.unauthorized" : "traffic.credentials.resolve-failed",
20714
+ summary: httpStatus ? `Vercel request-logs returned HTTP ${httpStatus}: ${msg}.` : `Vercel request-logs probe failed: ${msg}.`,
20715
+ 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."
20716
+ };
20717
+ }
20718
+ },
20719
+ // Vercel API tokens have no granular per-resource scopes — a token
20720
+ // inherits the user's team access, so there is no "missing scope"
20721
+ // failure mode. Surface a skipped result so the framework stays
20722
+ // uniform without producing a false signal.
20723
+ validateScopes: () => null
20724
+ };
20725
+ }
20285
20726
  return Object.keys(validators).length > 0 ? validators : void 0;
20286
20727
  }
20287
20728
 
@@ -22799,8 +23240,40 @@ function removeWordpressTrafficConnection(config, projectName) {
22799
23240
  return true;
22800
23241
  }
22801
23242
 
22802
- // src/wordpress-config.ts
23243
+ // src/vercel-traffic-config.ts
22803
23244
  function ensureConnections5(config) {
23245
+ if (!config.vercelTraffic) config.vercelTraffic = {};
23246
+ if (!config.vercelTraffic.connections) config.vercelTraffic.connections = [];
23247
+ return config.vercelTraffic.connections;
23248
+ }
23249
+ function getVercelTrafficConnection(config, projectName) {
23250
+ return (config.vercelTraffic?.connections ?? []).find((c) => c.projectName === projectName);
23251
+ }
23252
+ function upsertVercelTrafficConnection(config, connection) {
23253
+ const connections = ensureConnections5(config);
23254
+ const index = connections.findIndex((c) => c.projectName === connection.projectName);
23255
+ if (index === -1) {
23256
+ connections.push(connection);
23257
+ return connection;
23258
+ }
23259
+ connections[index] = connection;
23260
+ return connection;
23261
+ }
23262
+ function removeVercelTrafficConnection(config, projectName) {
23263
+ const connections = config.vercelTraffic?.connections;
23264
+ if (!connections?.length) return false;
23265
+ const next = connections.filter((c) => c.projectName !== projectName);
23266
+ if (next.length === connections.length) return false;
23267
+ if (!config.vercelTraffic) return false;
23268
+ config.vercelTraffic.connections = next;
23269
+ if (next.length === 0) {
23270
+ delete config.vercelTraffic;
23271
+ }
23272
+ return true;
23273
+ }
23274
+
23275
+ // src/wordpress-config.ts
23276
+ function ensureConnections6(config) {
22804
23277
  if (!config.wordpress) config.wordpress = {};
22805
23278
  if (!config.wordpress.connections) config.wordpress.connections = [];
22806
23279
  return config.wordpress.connections;
@@ -22817,7 +23290,7 @@ function getWordpressConnection(config, projectName) {
22817
23290
  return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
22818
23291
  }
22819
23292
  function upsertWordpressConnection(config, connection) {
22820
- const connections = ensureConnections5(config);
23293
+ const connections = ensureConnections6(config);
22821
23294
  const normalized = normalizeConnection(connection);
22822
23295
  const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
22823
23296
  if (index === -1) {
@@ -24523,6 +24996,7 @@ async function executeDiscoveryRun(opts) {
24523
24996
  icpDescription: opts.icpDescription,
24524
24997
  dedupThreshold: opts.dedupThreshold,
24525
24998
  maxProbes: opts.maxProbes,
24999
+ locations: opts.locations,
24526
25000
  deps
24527
25001
  });
24528
25002
  writeDiscoveryInsight(opts.db, {
@@ -24680,12 +25154,32 @@ function extractClassificationCategory(line) {
24680
25154
  }
24681
25155
  return null;
24682
25156
  }
25157
+ function formatLocationLine(location) {
25158
+ return [location.city, location.region, location.country].map((part) => part.trim()).filter(Boolean).join(", ");
25159
+ }
25160
+ function buildLocationConstraint(locations) {
25161
+ if (locations.length === 0) return [];
25162
+ const formatted = locations.map(formatLocationLine);
25163
+ if (locations.length === 1) {
25164
+ return [
25165
+ `The business serves ${formatted[0]}. Every query must be relevant to that service area \u2014 work the city or region into the query the way a real searcher would.`
25166
+ ];
25167
+ }
25168
+ const perLocation = Math.max(1, Math.floor(DEFAULT_SEED_COUNT / locations.length));
25169
+ return [
25170
+ "The business serves these locations:",
25171
+ ...formatted.map((line) => ` - ${line}`),
25172
+ `Generate at least ${perLocation} queries for EACH service area listed above so coverage stays balanced \u2014 do not let one area dominate. Every query must be relevant to at least one of these service areas, working the city or region into the query the way a real searcher would.`
25173
+ ];
25174
+ }
24683
25175
  function buildSeedPrompt(input) {
25176
+ const locationConstraint = buildLocationConstraint(input.locations ?? []);
24684
25177
  return [
24685
25178
  "You are an AEO (Answer Engine Optimization) analyst expanding a tracked-query basket for a customer.",
24686
25179
  "",
24687
25180
  `Customer: ${input.project.name} (domains: ${input.project.canonicalDomains.join(", ")})`,
24688
25181
  `ICP: ${input.icpDescription}`,
25182
+ ...locationConstraint.length > 0 ? ["", ...locationConstraint] : [],
24689
25183
  "",
24690
25184
  "Brainstorm a wide set of queries a member of this ICP would type into an AI answer engine (Gemini, ChatGPT, Perplexity) when they are about to make a decision in this space. Aim for 30+ candidates covering:",
24691
25185
  ' - Comparison queries ("best X for Y")',
@@ -27530,6 +28024,21 @@ async function createServer(opts) {
27530
28024
  return removed;
27531
28025
  }
27532
28026
  };
28027
+ const vercelTrafficCredentialStore = {
28028
+ getConnection: (projectName) => {
28029
+ return getVercelTrafficConnection(opts.config, projectName);
28030
+ },
28031
+ upsertConnection: (record) => {
28032
+ const updated = upsertVercelTrafficConnection(opts.config, record);
28033
+ saveConfigPatch(opts.config);
28034
+ return updated;
28035
+ },
28036
+ deleteConnection: (projectName) => {
28037
+ const removed = removeVercelTrafficConnection(opts.config, projectName);
28038
+ if (removed) saveConfigPatch(opts.config);
28039
+ return removed;
28040
+ }
28041
+ };
27533
28042
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto34.randomBytes(32).toString("hex");
27534
28043
  const googleConnectionStore = {
27535
28044
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -27832,7 +28341,8 @@ async function createServer(opts) {
27832
28341
  projectId: input.projectId,
27833
28342
  icpDescription: input.icpDescription,
27834
28343
  dedupThreshold: input.dedupThreshold,
27835
- maxProbes: input.maxProbes
28344
+ maxProbes: input.maxProbes,
28345
+ locations: input.locations
27836
28346
  }).then(() => runCoordinator.onRunCompleted(input.runId, input.projectId)).catch((err) => {
27837
28347
  app.log.error({ runId: input.runId, err }, "Discovery run failed");
27838
28348
  });
@@ -27889,6 +28399,7 @@ async function createServer(opts) {
27889
28399
  ga4CredentialStore,
27890
28400
  cloudRunCredentialStore,
27891
28401
  wordpressTrafficCredentialStore,
28402
+ vercelTrafficCredentialStore,
27892
28403
  onTrafficSynced: (event) => {
27893
28404
  trackEvent("traffic.synced", {
27894
28405
  status: event.status,