@ainyc/canonry 4.21.4 → 4.23.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-VFKGHXVJ.js";
8
+ } from "./chunk-VOSBGXXG.js";
9
9
  import {
10
10
  DEFAULT_RUN_HISTORY_LIMIT,
11
11
  IntelligenceService,
@@ -66,7 +66,7 @@ import {
66
66
  schedules,
67
67
  trafficSources,
68
68
  usageCounters
69
- } from "./chunk-GVQYROIK.js";
69
+ } from "./chunk-OYYFXKRK.js";
70
70
  import {
71
71
  AGENT_MEMORY_VALUE_MAX_BYTES,
72
72
  AGENT_PROVIDER_IDS,
@@ -154,12 +154,13 @@ import {
154
154
  serializeRunError,
155
155
  snapshotRequestSchema,
156
156
  summarizeCheckResults,
157
+ trafficConnectWordpressRequestSchema,
157
158
  unsupportedKind,
158
159
  validationError,
159
160
  visibilityStateFromAnswerMentioned,
160
161
  windowCutoff,
161
162
  wordpressEnvSchema
162
- } from "./chunk-EY63PENL.js";
163
+ } from "./chunk-EUGCQSFC.js";
163
164
 
164
165
  // src/telemetry.ts
165
166
  import crypto from "crypto";
@@ -10007,6 +10008,37 @@ var routeCatalog = [
10007
10008
  404: { description: "Project not found." }
10008
10009
  }
10009
10010
  },
10011
+ {
10012
+ method: "post",
10013
+ path: "/api/v1/projects/{name}/traffic/connect/wordpress",
10014
+ summary: "Connect a WordPress traffic-logger source",
10015
+ description: "Probes the WordPress traffic-logger plugin endpoint with the supplied Application Password (single page, `limit=1`) before persisting. On success, stores the credential in `~/.canonry/config.yaml` and creates / updates the project's active WordPress `traffic_sources` row. A probe failure (HTTP 4xx/5xx, network error) surfaces as 502 with the upstream status in the message so the caller learns about a bad credential up front instead of at the first sync.",
10016
+ tags: ["traffic"],
10017
+ parameters: [nameParameter],
10018
+ requestBody: {
10019
+ required: true,
10020
+ content: {
10021
+ "application/json": {
10022
+ schema: {
10023
+ type: "object",
10024
+ required: ["baseUrl", "username", "applicationPassword"],
10025
+ properties: {
10026
+ baseUrl: { ...stringSchema, description: "Absolute base URL of the WordPress site (e.g. `https://example.com`)." },
10027
+ username: { ...stringSchema, description: "WordPress username paired with the Application Password." },
10028
+ applicationPassword: { ...stringSchema, description: "WordPress Application Password (raw; the server base64-encodes it for Basic auth)." },
10029
+ displayName: stringSchema
10030
+ }
10031
+ }
10032
+ }
10033
+ }
10034
+ },
10035
+ responses: {
10036
+ 200: { description: "Traffic source DTO returned." },
10037
+ 400: { description: "Invalid WordPress connection request." },
10038
+ 404: { description: "Project not found." },
10039
+ 502: { description: "WordPress plugin endpoint probe failed (bad credentials, unreachable host, etc.)." }
10040
+ }
10041
+ },
10010
10042
  {
10011
10043
  method: "post",
10012
10044
  path: "/api/v1/projects/{name}/traffic/sources/{id}/sync",
@@ -16779,6 +16811,12 @@ function hostMatches(host, domain) {
16779
16811
  const normalizedDomain = normalizeHost(domain);
16780
16812
  return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
16781
16813
  }
16814
+ function utmTokenMatchesDomain(utmSource, domain) {
16815
+ if (hostMatches(utmSource, domain)) return true;
16816
+ const normalizedUtm = normalizeHost(utmSource);
16817
+ const firstLabel = normalizeHost(domain).split(".")[0];
16818
+ return Boolean(firstLabel) && normalizedUtm === firstLabel;
16819
+ }
16782
16820
  function hostFromUrl(value) {
16783
16821
  if (!value) return null;
16784
16822
  try {
@@ -16833,7 +16871,7 @@ function classifyAiReferral(event) {
16833
16871
  }
16834
16872
  const utmSource = utmSourceFromQuery(event.queryString);
16835
16873
  if (utmSource) {
16836
- const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(utmSource, candidate.domain));
16874
+ const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => utmTokenMatchesDomain(utmSource, candidate.domain));
16837
16875
  if (rule) {
16838
16876
  return {
16839
16877
  operator: rule.operator,
@@ -16845,7 +16883,7 @@ function classifyAiReferral(event) {
16845
16883
  }
16846
16884
  const refererUtmSource = utmSourceFromUrl(event.referer);
16847
16885
  if (refererUtmSource) {
16848
- const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => hostMatches(refererUtmSource, candidate.domain));
16886
+ const rule = DEFAULT_AI_REFERRER_RULES.find((candidate) => utmTokenMatchesDomain(refererUtmSource, candidate.domain));
16849
16887
  if (rule) {
16850
16888
  return {
16851
16889
  operator: rule.operator,
@@ -16971,20 +17009,19 @@ function buildTrafficProbeReport(events, options = {}) {
16971
17009
  incrementBucket(topAiReferralLandingPaths, pathNormalized, { landingPathNormalized: pathNormalized });
16972
17010
  }
16973
17011
  if (!crawler && !aiReferral) unknownHits += 1;
16974
- if (samples.length < sampleLimit) {
16975
- samples.push({
16976
- eventId: event.eventId,
16977
- observedAt: event.observedAt,
16978
- sourceType: event.sourceType,
16979
- path: event.path,
16980
- pathNormalized,
16981
- status: event.status,
16982
- userAgent: event.userAgent,
16983
- referer: event.referer,
16984
- crawler,
16985
- aiReferral
16986
- });
16987
- }
17012
+ samples.push({
17013
+ eventId: event.eventId,
17014
+ observedAt: event.observedAt,
17015
+ sourceType: event.sourceType,
17016
+ path: event.path,
17017
+ pathNormalized,
17018
+ status: event.status,
17019
+ userAgent: event.userAgent,
17020
+ referer: event.referer,
17021
+ crawler,
17022
+ aiReferral
17023
+ });
17024
+ if (samples.length > sampleLimit) samples.shift();
16988
17025
  }
16989
17026
  return {
16990
17027
  generatedAt: options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -17009,10 +17046,155 @@ function incrementBucket(map, key, fields) {
17009
17046
  else map.set(key, { fields, hits: 1 });
17010
17047
  }
17011
17048
 
17049
+ // ../integration-wordpress-traffic/src/normalize.ts
17050
+ function trimOrNull(value) {
17051
+ if (value === null || value === void 0) return null;
17052
+ const trimmed = value.trim();
17053
+ return trimmed.length > 0 ? trimmed : null;
17054
+ }
17055
+ function buildEventId2(event) {
17056
+ return `wordpress:${event.observed_at}:${event.id}`;
17057
+ }
17058
+ function normalizeWordpressTrafficEvent(event) {
17059
+ if (!event.observed_at) return null;
17060
+ if (typeof event.id !== "number" || !Number.isFinite(event.id)) return null;
17061
+ const path15 = event.path?.trim();
17062
+ if (!path15) return null;
17063
+ const queryString = trimOrNull(event.query_string);
17064
+ const host = trimOrNull(event.host);
17065
+ const requestUrl = host ? `https://${host}${path15}${queryString ? `?${queryString}` : ""}` : `${path15}${queryString ? `?${queryString}` : ""}`;
17066
+ return {
17067
+ sourceType: TrafficSourceTypes.wordpress,
17068
+ evidenceKind: TrafficEvidenceKinds["raw-request"],
17069
+ confidence: TrafficEventConfidences.observed,
17070
+ eventId: buildEventId2(event),
17071
+ observedAt: event.observed_at,
17072
+ method: trimOrNull(event.method),
17073
+ requestUrl,
17074
+ host,
17075
+ path: path15,
17076
+ queryString,
17077
+ status: typeof event.status === "number" && Number.isFinite(event.status) ? event.status : null,
17078
+ userAgent: trimOrNull(event.user_agent),
17079
+ remoteIp: trimOrNull(event.remote_ip_hash),
17080
+ referer: trimOrNull(event.referer),
17081
+ latencyMs: null,
17082
+ requestSizeBytes: null,
17083
+ responseSizeBytes: null,
17084
+ providerResource: {
17085
+ type: "wordpress_site",
17086
+ labels: host ? { host } : {}
17087
+ },
17088
+ providerLabels: {}
17089
+ };
17090
+ }
17091
+
17092
+ // ../integration-wordpress-traffic/src/client.ts
17093
+ var WORDPRESS_TRAFFIC_ENDPOINT_PATH = "/wp-json/canonry/v1/events";
17094
+ var DEFAULT_PAGE_SIZE2 = 500;
17095
+ var DEFAULT_MAX_PAGES2 = 1;
17096
+ var DEFAULT_TIMEOUT_MS2 = 3e4;
17097
+ var WordpressTrafficApiError = class extends Error {
17098
+ constructor(message, status, body) {
17099
+ super(message);
17100
+ this.status = status;
17101
+ this.body = body;
17102
+ this.name = "WordpressTrafficApiError";
17103
+ }
17104
+ };
17105
+ function trimRequired(name, value) {
17106
+ const trimmed = value.trim();
17107
+ if (!trimmed) {
17108
+ throw new WordpressTrafficApiError(`${name} is required`, 400);
17109
+ }
17110
+ return trimmed;
17111
+ }
17112
+ function normalizePageSize2(pageSize) {
17113
+ if (pageSize === void 0) return DEFAULT_PAGE_SIZE2;
17114
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
17115
+ throw new WordpressTrafficApiError("pageSize must be a positive integer", 400);
17116
+ }
17117
+ return pageSize;
17118
+ }
17119
+ function normalizeMaxPages2(maxPages) {
17120
+ if (maxPages === void 0) return DEFAULT_MAX_PAGES2;
17121
+ if (!Number.isInteger(maxPages) || maxPages < 1) {
17122
+ throw new WordpressTrafficApiError("maxPages must be a positive integer", 400);
17123
+ }
17124
+ return maxPages;
17125
+ }
17126
+ function resolveEndpoint(baseUrl) {
17127
+ const trimmed = trimRequired("baseUrl", baseUrl).replace(/\/+$/, "");
17128
+ return `${trimmed}${WORDPRESS_TRAFFIC_ENDPOINT_PATH}`;
17129
+ }
17130
+ function buildBasicAuthHeader(username, applicationPassword) {
17131
+ const credentials = `${trimRequired("username", username)}:${trimRequired("applicationPassword", applicationPassword)}`;
17132
+ return `Basic ${Buffer.from(credentials, "utf8").toString("base64")}`;
17133
+ }
17134
+ async function readErrorBody2(response) {
17135
+ const text = await response.text().catch(() => "");
17136
+ if (!text) return void 0;
17137
+ return text.length <= 500 ? text : `${text.slice(0, 500)}... [truncated]`;
17138
+ }
17139
+ async function listWordpressTrafficEvents(options) {
17140
+ const endpoint = resolveEndpoint(options.baseUrl);
17141
+ const authHeader = buildBasicAuthHeader(options.username, options.applicationPassword);
17142
+ const pageSize = normalizePageSize2(options.pageSize);
17143
+ const maxPages = normalizeMaxPages2(options.maxPages);
17144
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
17145
+ let cursor = options.cursor;
17146
+ let rawEntryCount = 0;
17147
+ let skippedEntryCount = 0;
17148
+ const events = [];
17149
+ for (let page = 0; page < maxPages; page += 1) {
17150
+ const url = new URL(endpoint);
17151
+ url.searchParams.set("limit", String(pageSize));
17152
+ if (cursor !== void 0 && cursor !== "") {
17153
+ url.searchParams.set("cursor", cursor);
17154
+ }
17155
+ const response = await fetch(url, {
17156
+ method: "GET",
17157
+ headers: {
17158
+ Authorization: authHeader,
17159
+ Accept: "application/json"
17160
+ },
17161
+ signal: AbortSignal.timeout(timeoutMs)
17162
+ });
17163
+ if (!response.ok) {
17164
+ const body2 = await readErrorBody2(response);
17165
+ throw new WordpressTrafficApiError(
17166
+ `WordPress traffic endpoint returned HTTP ${response.status}`,
17167
+ response.status,
17168
+ body2
17169
+ );
17170
+ }
17171
+ const body = await response.json();
17172
+ const entries = body.events ?? [];
17173
+ rawEntryCount += entries.length;
17174
+ for (const entry of entries) {
17175
+ const normalized = normalizeWordpressTrafficEvent(entry);
17176
+ if (normalized) {
17177
+ events.push(normalized);
17178
+ } else {
17179
+ skippedEntryCount += 1;
17180
+ }
17181
+ }
17182
+ cursor = body.next_cursor ?? void 0;
17183
+ if (!body.has_more || !cursor) break;
17184
+ }
17185
+ return {
17186
+ events,
17187
+ rawEntryCount,
17188
+ skippedEntryCount,
17189
+ nextCursor: cursor,
17190
+ endpoint
17191
+ };
17192
+ }
17193
+
17012
17194
  // ../api-routes/src/traffic.ts
17013
17195
  var DEFAULT_SYNC_WINDOW_MINUTES = 43200;
17014
- var DEFAULT_PAGE_SIZE2 = 1e3;
17015
- var DEFAULT_MAX_PAGES2 = 5;
17196
+ var DEFAULT_PAGE_SIZE3 = 1e3;
17197
+ var DEFAULT_MAX_PAGES3 = 5;
17016
17198
  var DEFAULT_SAMPLE_LIMIT2 = 100;
17017
17199
  var MAX_TRACKED_EVENT_IDS = 1e3;
17018
17200
  var DEFAULT_BACKFILL_DAYS = 30;
@@ -17089,7 +17271,7 @@ async function runBackfillTask(options) {
17089
17271
  location,
17090
17272
  startTime: windowStart.toISOString(),
17091
17273
  endTime: windowEnd.toISOString(),
17092
- pageSize: DEFAULT_PAGE_SIZE2,
17274
+ pageSize: DEFAULT_PAGE_SIZE3,
17093
17275
  maxPages: BACKFILL_MAX_PAGES,
17094
17276
  // Backfill is intentionally `firstSync: false`. We don't want desc
17095
17277
  // ordering — the in-memory rollup builder handles any order, and the
@@ -17118,7 +17300,7 @@ async function runBackfillTask(options) {
17118
17300
  const newSorted = allEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17119
17301
  const newRingBuffer = newSorted.slice(0, MAX_TRACKED_EVENT_IDS);
17120
17302
  const currentLastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
17121
- const nextLastSyncedAt = Math.max(currentLastSyncedMs, windowEnd.getTime()) === windowEnd.getTime() ? finishedAt : sourceRow.lastSyncedAt;
17303
+ const nextLastSyncedAt = Math.max(currentLastSyncedMs, windowEnd.getTime()) === windowEnd.getTime() ? windowEndIso : sourceRow.lastSyncedAt;
17122
17304
  try {
17123
17305
  app.db.transaction((tx) => {
17124
17306
  tx.delete(crawlerEventsHourly).where(
@@ -17219,9 +17401,10 @@ async function runBackfillTask(options) {
17219
17401
  async function trafficRoutes(app, opts) {
17220
17402
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
17221
17403
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
17404
+ const pullWordpressEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
17222
17405
  const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
17223
- const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE2;
17224
- const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES2;
17406
+ const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
17407
+ const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES3;
17225
17408
  const sampleLimit = opts.defaultSampleLimit ?? DEFAULT_SAMPLE_LIMIT2;
17226
17409
  app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
17227
17410
  const project = resolveProject(app.db, request.params.name);
@@ -17305,6 +17488,84 @@ async function trafficRoutes(app, opts) {
17305
17488
  });
17306
17489
  return rowToDto(sourceRow);
17307
17490
  });
17491
+ app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
17492
+ const project = resolveProject(app.db, request.params.name);
17493
+ if (!opts.wordpressTrafficCredentialStore) {
17494
+ throw validationError("WordPress traffic credential storage is not configured for this deployment");
17495
+ }
17496
+ const credentialStore = opts.wordpressTrafficCredentialStore;
17497
+ const parsed = trafficConnectWordpressRequestSchema.safeParse(request.body ?? {});
17498
+ if (!parsed.success) {
17499
+ throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
17500
+ }
17501
+ const { baseUrl, username, applicationPassword, displayName } = parsed.data;
17502
+ try {
17503
+ await pullWordpressEvents({
17504
+ baseUrl,
17505
+ username,
17506
+ applicationPassword,
17507
+ pageSize: 1,
17508
+ maxPages: 1
17509
+ });
17510
+ } catch (e) {
17511
+ if (e instanceof WordpressTrafficApiError) {
17512
+ throw providerError(
17513
+ `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
17514
+ );
17515
+ }
17516
+ const msg = e instanceof Error ? e.message : String(e);
17517
+ throw providerError(`WordPress traffic probe failed: ${msg}`);
17518
+ }
17519
+ const now = (/* @__PURE__ */ new Date()).toISOString();
17520
+ const existing = credentialStore.getConnection(project.name);
17521
+ credentialStore.upsertConnection({
17522
+ projectName: project.name,
17523
+ baseUrl,
17524
+ username,
17525
+ applicationPassword,
17526
+ createdAt: existing?.createdAt ?? now,
17527
+ updatedAt: now
17528
+ });
17529
+ const activeSource = app.db.select().from(trafficSources).where(eq23(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.wordpress && row.status !== TrafficSourceStatuses.archived);
17530
+ const config = { baseUrl, username };
17531
+ const fallbackName = displayName ?? `WordPress \xB7 ${new URL(baseUrl).host}`;
17532
+ let sourceRow;
17533
+ if (activeSource) {
17534
+ app.db.update(trafficSources).set({
17535
+ displayName: fallbackName,
17536
+ status: TrafficSourceStatuses.connected,
17537
+ lastError: null,
17538
+ configJson: JSON.stringify(config),
17539
+ updatedAt: now
17540
+ }).where(eq23(trafficSources.id, activeSource.id)).run();
17541
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, activeSource.id)).get();
17542
+ } else {
17543
+ const newId = crypto20.randomUUID();
17544
+ app.db.insert(trafficSources).values({
17545
+ id: newId,
17546
+ projectId: project.id,
17547
+ sourceType: TrafficSourceTypes.wordpress,
17548
+ displayName: fallbackName,
17549
+ status: TrafficSourceStatuses.connected,
17550
+ lastSyncedAt: null,
17551
+ lastCursor: null,
17552
+ lastError: null,
17553
+ archivedAt: null,
17554
+ configJson: JSON.stringify(config),
17555
+ createdAt: now,
17556
+ updatedAt: now
17557
+ }).run();
17558
+ sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, newId)).get();
17559
+ }
17560
+ writeAuditLog(app.db, {
17561
+ projectId: project.id,
17562
+ actor: "api",
17563
+ action: "traffic.wordpress.connected",
17564
+ entityType: "traffic_source",
17565
+ entityId: sourceRow.id
17566
+ });
17567
+ return rowToDto(sourceRow);
17568
+ });
17308
17569
  app.post("/projects/:name/traffic/sources/:id/sync", async (request) => {
17309
17570
  const project = resolveProject(app.db, request.params.name);
17310
17571
  const sourceRow = app.db.select().from(trafficSources).where(eq23(trafficSources.id, request.params.id)).get();
@@ -17398,25 +17659,35 @@ async function trafficRoutes(app, opts) {
17398
17659
  markFailed(msg, "PROVIDER_PULL");
17399
17660
  throw providerError(`Cloud Run pull failed: ${msg}`);
17400
17661
  }
17401
- const seenEventIds = new Set(parseJsonColumn(sourceRow.lastEventIds, []));
17402
- const dedupedEvents = seenEventIds.size === 0 ? allEvents : allEvents.filter((e) => !seenEventIds.has(e.eventId));
17403
- const newSorted = dedupedEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17404
- const previousIds = parseJsonColumn(sourceRow.lastEventIds, []);
17405
- const merged = [];
17406
- const mergedSet = /* @__PURE__ */ new Set();
17407
- for (const id of [...newSorted, ...previousIds]) {
17408
- if (mergedSet.has(id)) continue;
17409
- mergedSet.add(id);
17410
- merged.push(id);
17411
- if (merged.length >= MAX_TRACKED_EVENT_IDS) break;
17412
- }
17413
- const nextEventIds = merged;
17414
- const report = buildTrafficProbeReport(dedupedEvents, { sampleLimit });
17415
- const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17416
17662
  let crawlerBucketRows = 0;
17417
17663
  let aiReferralBucketRows = 0;
17418
17664
  let sampleRows = 0;
17665
+ let finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17666
+ let pulledEventsCount = 0;
17667
+ let crawlerHitsCount = 0;
17668
+ let aiReferralHitsCount = 0;
17669
+ let unknownHitsCount = 0;
17419
17670
  app.db.transaction((tx) => {
17671
+ const latestRow = tx.select().from(trafficSources).where(eq23(trafficSources.id, sourceRow.id)).get();
17672
+ const previousIds = parseJsonColumn(latestRow.lastEventIds, []);
17673
+ const seenEventIds = new Set(previousIds);
17674
+ const dedupedEvents = seenEventIds.size === 0 ? allEvents : allEvents.filter((e) => !seenEventIds.has(e.eventId));
17675
+ const newSorted = dedupedEvents.slice().sort((a, b) => a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0).map((e) => e.eventId);
17676
+ const merged = [];
17677
+ const mergedSet = /* @__PURE__ */ new Set();
17678
+ for (const id of [...newSorted, ...previousIds]) {
17679
+ if (mergedSet.has(id)) continue;
17680
+ mergedSet.add(id);
17681
+ merged.push(id);
17682
+ if (merged.length >= MAX_TRACKED_EVENT_IDS) break;
17683
+ }
17684
+ const nextEventIds = merged;
17685
+ const report = buildTrafficProbeReport(dedupedEvents, { sampleLimit });
17686
+ finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17687
+ pulledEventsCount = report.totals.normalizedEvents;
17688
+ crawlerHitsCount = report.totals.crawlerHits;
17689
+ aiReferralHitsCount = report.totals.aiReferralHits;
17690
+ unknownHitsCount = report.totals.unknownHits;
17420
17691
  for (const bucket of report.crawlerEventsHourly) {
17421
17692
  const status = bucket.status ?? 0;
17422
17693
  tx.insert(crawlerEventsHourly).values({
@@ -17515,7 +17786,11 @@ async function trafficRoutes(app, opts) {
17515
17786
  }
17516
17787
  tx.update(trafficSources).set({
17517
17788
  status: TrafficSourceStatuses.connected,
17518
- lastSyncedAt: finishedAt,
17789
+ // Advance to windowEnd, not finishedAt — events arriving at the
17790
+ // source between windowEnd and finishedAt aren't in this pull's
17791
+ // range. If we stored finishedAt, the next sync's clamp would skip
17792
+ // past them and they'd be lost.
17793
+ lastSyncedAt: windowEnd.toISOString(),
17519
17794
  lastError: null,
17520
17795
  lastEventIds: JSON.stringify(nextEventIds),
17521
17796
  updatedAt: finishedAt
@@ -17534,9 +17809,9 @@ async function trafficRoutes(app, opts) {
17534
17809
  status: "completed",
17535
17810
  sourceType: sourceRow.sourceType,
17536
17811
  sourceId: sourceRow.id,
17537
- pulledEvents: report.totals.normalizedEvents,
17538
- crawlerHits: report.totals.crawlerHits,
17539
- aiReferralHits: report.totals.aiReferralHits,
17812
+ pulledEvents: pulledEventsCount,
17813
+ crawlerHits: crawlerHitsCount,
17814
+ aiReferralHits: aiReferralHitsCount,
17540
17815
  durationMs: Date.now() - syncStartedAtMs
17541
17816
  });
17542
17817
  } catch {
@@ -17545,10 +17820,10 @@ async function trafficRoutes(app, opts) {
17545
17820
  sourceId: sourceRow.id,
17546
17821
  runId,
17547
17822
  syncedAt: finishedAt,
17548
- pulledEvents: report.totals.normalizedEvents,
17549
- crawlerHits: report.totals.crawlerHits,
17550
- aiReferralHits: report.totals.aiReferralHits,
17551
- unknownHits: report.totals.unknownHits,
17823
+ pulledEvents: pulledEventsCount,
17824
+ crawlerHits: crawlerHitsCount,
17825
+ aiReferralHits: aiReferralHitsCount,
17826
+ unknownHits: unknownHitsCount,
17552
17827
  crawlerBucketRows,
17553
17828
  aiReferralBucketRows,
17554
17829
  sampleRows,
@@ -18548,8 +18823,16 @@ var recentDataCheck = {
18548
18823
  )
18549
18824
  ).get()?.total ?? 0
18550
18825
  );
18826
+ const olderReferrals = Number(
18827
+ ctx.db.select({ total: sql9`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
18828
+ and15(
18829
+ eq24(aiReferralEventsHourly.projectId, ctx.project.id),
18830
+ gte3(aiReferralEventsHourly.tsHour, failCutoff)
18831
+ )
18832
+ ).get()?.total ?? 0
18833
+ );
18551
18834
  const lastSyncedAt = sources.map((s) => s.lastSyncedAt).filter(Boolean).sort().at(-1) ?? null;
18552
- if (olderCrawlers > 0 || lastSyncedAt) {
18835
+ if (olderCrawlers > 0 || olderReferrals > 0 || lastSyncedAt) {
18553
18836
  return {
18554
18837
  status: CheckStatuses.warn,
18555
18838
  code: "traffic.recent-data.stale",
@@ -18955,6 +19238,8 @@ async function apiRoutes(app, opts) {
18955
19238
  cloudRunCredentialStore: opts.cloudRunCredentialStore,
18956
19239
  pullCloudRunEvents: opts.pullCloudRunEvents,
18957
19240
  resolveCloudRunAccessToken: opts.resolveCloudRunAccessToken,
19241
+ wordpressTrafficCredentialStore: opts.wordpressTrafficCredentialStore,
19242
+ pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
18958
19243
  onTrafficSynced: opts.onTrafficSynced
18959
19244
  });
18960
19245
  await api.register(backlinksRoutes, {
@@ -19021,6 +19306,50 @@ function buildTrafficSourceValidators(opts) {
19021
19306
  validateScopes: () => null
19022
19307
  };
19023
19308
  }
19309
+ if (opts.wordpressTrafficCredentialStore) {
19310
+ const store = opts.wordpressTrafficCredentialStore;
19311
+ const pullEvents = opts.pullWordpressTrafficEvents ?? listWordpressTrafficEvents;
19312
+ validators[TrafficSourceTypes.wordpress] = {
19313
+ validateCredentials: async (source) => {
19314
+ const record = store.getConnection(source.projectName);
19315
+ if (!record) {
19316
+ return {
19317
+ status: CheckStatuses.fail,
19318
+ code: "traffic.credentials.missing",
19319
+ summary: `No WordPress traffic credential found in ~/.canonry/config.yaml for project "${source.projectName}".`,
19320
+ remediation: "Re-run `canonry traffic connect wordpress <project> --url <site> --username <user> --app-password <password>`."
19321
+ };
19322
+ }
19323
+ try {
19324
+ await pullEvents({
19325
+ baseUrl: record.baseUrl,
19326
+ username: record.username,
19327
+ applicationPassword: record.applicationPassword,
19328
+ pageSize: 1,
19329
+ maxPages: 1
19330
+ });
19331
+ return {
19332
+ status: CheckStatuses.ok,
19333
+ code: "traffic.credentials.resolved",
19334
+ summary: `WordPress endpoint responds for "${source.displayName}" (${new URL(record.baseUrl).host}).`
19335
+ };
19336
+ } catch (e) {
19337
+ const httpStatus = e instanceof WordpressTrafficApiError ? e.status : null;
19338
+ const msg = e instanceof Error ? e.message : String(e);
19339
+ return {
19340
+ status: CheckStatuses.fail,
19341
+ code: httpStatus === 401 || httpStatus === 403 ? "traffic.credentials.unauthorized" : "traffic.credentials.resolve-failed",
19342
+ summary: httpStatus ? `WordPress endpoint returned HTTP ${httpStatus}: ${msg}.` : `WordPress endpoint probe failed: ${msg}.`,
19343
+ remediation: "Verify the site URL is reachable and the Application Password is valid. Re-connect the source if needed."
19344
+ };
19345
+ }
19346
+ },
19347
+ // WordPress Application Passwords have no scope concept — auth is
19348
+ // strictly "valid credential or not". Surface a skipped result so the
19349
+ // framework is uniform without producing a false signal.
19350
+ validateScopes: () => null
19351
+ };
19352
+ }
19024
19353
  return Object.keys(validators).length > 0 ? validators : void 0;
19025
19354
  }
19026
19355
 
@@ -21458,8 +21787,40 @@ function removeCloudRunConnection(config, projectName) {
21458
21787
  return true;
21459
21788
  }
21460
21789
 
21461
- // src/wordpress-config.ts
21790
+ // src/wordpress-traffic-config.ts
21462
21791
  function ensureConnections4(config) {
21792
+ if (!config.wordpressTraffic) config.wordpressTraffic = {};
21793
+ if (!config.wordpressTraffic.connections) config.wordpressTraffic.connections = [];
21794
+ return config.wordpressTraffic.connections;
21795
+ }
21796
+ function getWordpressTrafficConnection(config, projectName) {
21797
+ return (config.wordpressTraffic?.connections ?? []).find((c) => c.projectName === projectName);
21798
+ }
21799
+ function upsertWordpressTrafficConnection(config, connection) {
21800
+ const connections = ensureConnections4(config);
21801
+ const index = connections.findIndex((c) => c.projectName === connection.projectName);
21802
+ if (index === -1) {
21803
+ connections.push(connection);
21804
+ return connection;
21805
+ }
21806
+ connections[index] = connection;
21807
+ return connection;
21808
+ }
21809
+ function removeWordpressTrafficConnection(config, projectName) {
21810
+ const connections = config.wordpressTraffic?.connections;
21811
+ if (!connections?.length) return false;
21812
+ const next = connections.filter((c) => c.projectName !== projectName);
21813
+ if (next.length === connections.length) return false;
21814
+ if (!config.wordpressTraffic) return false;
21815
+ config.wordpressTraffic.connections = next;
21816
+ if (next.length === 0) {
21817
+ delete config.wordpressTraffic;
21818
+ }
21819
+ return true;
21820
+ }
21821
+
21822
+ // src/wordpress-config.ts
21823
+ function ensureConnections5(config) {
21463
21824
  if (!config.wordpress) config.wordpress = {};
21464
21825
  if (!config.wordpress.connections) config.wordpress.connections = [];
21465
21826
  return config.wordpress.connections;
@@ -21476,7 +21837,7 @@ function getWordpressConnection(config, projectName) {
21476
21837
  return (config.wordpress?.connections ?? []).find((connection) => connection.projectName === projectName);
21477
21838
  }
21478
21839
  function upsertWordpressConnection(config, connection) {
21479
- const connections = ensureConnections4(config);
21840
+ const connections = ensureConnections5(config);
21480
21841
  const normalized = normalizeConnection(connection);
21481
21842
  const index = connections.findIndex((entry) => entry.projectName === connection.projectName);
21482
21843
  if (index === -1) {
@@ -25808,6 +26169,21 @@ async function createServer(opts) {
25808
26169
  return removed;
25809
26170
  }
25810
26171
  };
26172
+ const wordpressTrafficCredentialStore = {
26173
+ getConnection: (projectName) => {
26174
+ return getWordpressTrafficConnection(opts.config, projectName);
26175
+ },
26176
+ upsertConnection: (record) => {
26177
+ const updated = upsertWordpressTrafficConnection(opts.config, record);
26178
+ saveConfigPatch(opts.config);
26179
+ return updated;
26180
+ },
26181
+ deleteConnection: (projectName) => {
26182
+ const removed = removeWordpressTrafficConnection(opts.config, projectName);
26183
+ if (removed) saveConfigPatch(opts.config);
26184
+ return removed;
26185
+ }
26186
+ };
25811
26187
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto31.randomBytes(32).toString("hex");
25812
26188
  const googleConnectionStore = {
25813
26189
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
@@ -26152,6 +26528,7 @@ async function createServer(opts) {
26152
26528
  wordpressConnectionStore,
26153
26529
  ga4CredentialStore,
26154
26530
  cloudRunCredentialStore,
26531
+ wordpressTrafficCredentialStore,
26155
26532
  onTrafficSynced: (event) => {
26156
26533
  trackEvent("traffic.synced", {
26157
26534
  status: event.status,
@@ -2227,6 +2227,10 @@ var cloudRunSourceConfigSchema = z20.object({
2227
2227
  location: z20.string().nullable().optional(),
2228
2228
  authMode: trafficSourceAuthModeSchema
2229
2229
  });
2230
+ var wordpressTrafficSourceConfigSchema = z20.object({
2231
+ baseUrl: z20.string().url(),
2232
+ username: z20.string().min(1)
2233
+ });
2230
2234
  var trafficSourceDtoSchema = z20.object({
2231
2235
  id: z20.string(),
2232
2236
  projectId: z20.string(),
@@ -2249,6 +2253,13 @@ var trafficConnectCloudRunRequestSchema = z20.object({
2249
2253
  /** Service-account JSON content (string). When omitted, defaults to OAuth via `canonry google connect <project> --type ga4` flow. */
2250
2254
  keyJson: z20.string().optional()
2251
2255
  });
2256
+ var trafficConnectWordpressRequestSchema = z20.object({
2257
+ baseUrl: z20.string().url(),
2258
+ username: z20.string().min(1),
2259
+ /** WordPress Application Password (the same auth used by the content client). */
2260
+ applicationPassword: z20.string().min(1),
2261
+ displayName: z20.string().min(1).optional()
2262
+ });
2252
2263
  var trafficSyncResponseSchema = z20.object({
2253
2264
  sourceId: z20.string(),
2254
2265
  runId: z20.string(),
@@ -2494,6 +2505,7 @@ export {
2494
2505
  TrafficSourceAuthModes,
2495
2506
  VerificationStatuses,
2496
2507
  trafficConnectCloudRunRequestSchema,
2508
+ trafficConnectWordpressRequestSchema,
2497
2509
  trafficEventKindSchema,
2498
2510
  TrafficEventKinds,
2499
2511
  formatRatio,