@canonry/canonry 7.22.0 → 7.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +14 -0
- package/assets/assets/{AuditHistoryPanel-J8AR3x5F.js → AuditHistoryPanel-DKKnX8IT.js} +1 -1
- package/assets/assets/{BacklinksPage-CRDPutGA.js → BacklinksPage-DNbur8am.js} +1 -1
- package/assets/assets/{HistoryPage-C5taVW3t.js → HistoryPage-DahtHlpM.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-txucmLuz.js → MeasurementPropertyPage-BoNDtLfp.js} +1 -1
- package/assets/assets/{ProjectPage-C4FaBoWI.js → ProjectPage--ZlpQduH.js} +1 -1
- package/assets/assets/{RunsPage-D-3XKUhh.js → RunsPage-DKB2CHTM.js} +1 -1
- package/assets/assets/{SettingsPage-Cq_SJRj_.js → SettingsPage-lnmMZzEj.js} +1 -1
- package/assets/assets/{SiteHealthSection-Cn_IZVO1.js → SiteHealthSection-BBjucRed.js} +3 -3
- package/assets/assets/{TrafficPage-BMdFZs3X.js → TrafficPage-DNyTmPLd.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-BmrIOc-2.js → TrafficSourceDetailPage-D1zKZHSh.js} +1 -1
- package/assets/assets/{extract-error-message-HlQy5L7O.js → extract-error-message-v0b8P-wc.js} +1 -1
- package/assets/assets/index-CfaBRiBG.js +86 -0
- package/assets/assets/{react-sigma_core.esm.min-CogAr1i4.js → react-sigma_core.esm.min-BZSJoRrV.js} +1 -1
- package/assets/assets/{v2-overview-adapter-tps7TUxq.js → v2-overview-adapter-DXAl-O_h.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-222NAFTY.js → chunk-7Q7QCBJ3.js} +2 -2
- package/dist/{chunk-5IXQAOVK.js → chunk-DS2ZKB3T.js} +15 -6
- package/dist/{chunk-MRKV43TV.js → chunk-IM2BFAAT.js} +565 -206
- package/dist/{chunk-NDQOCFJQ.js → chunk-JJBVYL45.js} +39 -4
- package/dist/{chunk-46JZR2IV.js → chunk-O5UMRQVN.js} +1303 -504
- package/dist/{chunk-6Z2LFFRT.js → chunk-SG3MPCDL.js} +5731 -4596
- package/dist/{chunk-6H2BC5EY.js → chunk-YCPTP2NC.js} +3 -3
- package/dist/cli.js +371 -143
- package/dist/{demo-server-32PU7BM6.js → demo-server-C7UGXXN7.js} +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -6
- package/dist/{intelligence-service-256DQBVR.js → intelligence-service-37TX4YV7.js} +2 -2
- package/dist/mcp.js +4 -4
- package/package.json +13 -13
- package/assets/assets/index-BJcjB5XW.js +0 -86
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
DiscoveryCompetitorTypes,
|
|
65
65
|
DiscoveryPromoteCompetitorSkipReasons,
|
|
66
66
|
DiscoverySessionStatuses,
|
|
67
|
+
FeatureNames,
|
|
67
68
|
GA_SEARCH_LANDING_DEFAULT_LIMIT,
|
|
68
69
|
GA_SEARCH_LANDING_DEFAULT_WINDOW,
|
|
69
70
|
GA_SEARCH_LANDING_MAX_LIMIT,
|
|
@@ -118,11 +119,15 @@ import {
|
|
|
118
119
|
OPERATIONAL_LOG_FIELDS_HEADER,
|
|
119
120
|
OPERATIONAL_LOG_OPT_IN_CONTEXT_FIELDS,
|
|
120
121
|
OUTSTANDING_PROVIDER_BATCH_STATUSES,
|
|
122
|
+
OutcomeReasonCodes,
|
|
123
|
+
OutcomeStatuses,
|
|
124
|
+
OutcomeTriggers,
|
|
121
125
|
POOLED_RUN_ID_LIMIT,
|
|
122
126
|
PROJECTS_WRITE_SCOPE,
|
|
123
127
|
PROJECT_OVERVIEW_QUERY_CLASS_SCOPE,
|
|
124
128
|
PROVIDER_ACCOUNT_FAILURE_STREAK,
|
|
125
129
|
PROVIDER_ACCOUNT_RETRY_HOURS,
|
|
130
|
+
PROVIDER_NAMES,
|
|
126
131
|
ProviderDispatchModes,
|
|
127
132
|
READ_ONLY_SCOPE,
|
|
128
133
|
RESEARCH_RUN_SCOPE,
|
|
@@ -264,6 +269,7 @@ import {
|
|
|
264
269
|
brandLabelFromDomain,
|
|
265
270
|
brandMetricsDtoSchema,
|
|
266
271
|
breakdownShares,
|
|
272
|
+
bucketDuration,
|
|
267
273
|
buildHarvestAnchorTerms,
|
|
268
274
|
buildMeasurementExecutionIdentity,
|
|
269
275
|
buildMeasurementRunManifestV1,
|
|
@@ -293,6 +299,7 @@ import {
|
|
|
293
299
|
citationVisibilityResponseSchema,
|
|
294
300
|
classifyAiReferralTrafficClass,
|
|
295
301
|
classifyCitedSurface,
|
|
302
|
+
classifyOutcomeError,
|
|
296
303
|
classifyProviderErrorMessage,
|
|
297
304
|
classifySkillFile,
|
|
298
305
|
classifySurfaceFromCategory,
|
|
@@ -610,6 +617,7 @@ import {
|
|
|
610
617
|
nextScheduleUpdatedAt,
|
|
611
618
|
noProvider,
|
|
612
619
|
noQueries,
|
|
620
|
+
normalizeAgentSlug,
|
|
613
621
|
normalizeCompetitorAliases,
|
|
614
622
|
normalizeCompetitorDomain,
|
|
615
623
|
normalizeIdTokens,
|
|
@@ -640,6 +648,7 @@ import {
|
|
|
640
648
|
orderLocationsDefaultFirst,
|
|
641
649
|
organicEvidenceDtoSchema,
|
|
642
650
|
organicEvidencePeriodSchema,
|
|
651
|
+
outcomeSurfaceSchema,
|
|
643
652
|
parseBoundedRate,
|
|
644
653
|
parseInclusiveEndMs,
|
|
645
654
|
parseMeasurementDraftEtagVersion,
|
|
@@ -855,7 +864,7 @@ import {
|
|
|
855
864
|
wordpressSchemaDeployResultDtoSchema,
|
|
856
865
|
wordpressSchemaStatusResultDtoSchema,
|
|
857
866
|
wordpressStatusDtoSchema
|
|
858
|
-
} from "./chunk-
|
|
867
|
+
} from "./chunk-IM2BFAAT.js";
|
|
859
868
|
|
|
860
869
|
// src/intelligence-service.ts
|
|
861
870
|
import { eq as eq89, desc as desc41, asc as asc18, and as and75, ne as ne16, or as or22, inArray as inArray36, gte as gte24, lte as lte20, isNull as isNull17, sql as sql32, exists as exists2 } from "drizzle-orm";
|
|
@@ -13118,12 +13127,28 @@ import { eq as eq5, ne, sql as sql5 } from "drizzle-orm";
|
|
|
13118
13127
|
import { AsyncLocalStorage } from "async_hooks";
|
|
13119
13128
|
var requestContext = new AsyncLocalStorage();
|
|
13120
13129
|
var MAX_REQUEST_CONTEXT_LENGTH = 512;
|
|
13130
|
+
var ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,39}$/;
|
|
13131
|
+
var MAX_ERROR_BODY_BYTES = 16384;
|
|
13132
|
+
function errorCodeOfBody(payload) {
|
|
13133
|
+
const text2 = typeof payload === "string" ? payload : Buffer.isBuffer(payload) ? payload.toString("utf8") : void 0;
|
|
13134
|
+
if (!text2 || text2.length > MAX_ERROR_BODY_BYTES || !text2.trimStart().startsWith("{")) return void 0;
|
|
13135
|
+
try {
|
|
13136
|
+
const code = JSON.parse(text2).error?.code;
|
|
13137
|
+
return typeof code === "string" && ERROR_CODE_PATTERN.test(code) ? code : void 0;
|
|
13138
|
+
} catch {
|
|
13139
|
+
return void 0;
|
|
13140
|
+
}
|
|
13141
|
+
}
|
|
13121
13142
|
function getRequestContext(boundRequestId) {
|
|
13122
13143
|
const context = requestContext.getStore();
|
|
13123
13144
|
if (!context || context.completed && context.requestId !== boundRequestId) return void 0;
|
|
13124
13145
|
const { completed: _completed, ...safeContext } = context;
|
|
13125
13146
|
return safeContext;
|
|
13126
13147
|
}
|
|
13148
|
+
function currentOutcomeAttribution() {
|
|
13149
|
+
const context = getRequestContext();
|
|
13150
|
+
return context ? { userAgent: context.userAgent, surfaceLabel: context.usageSurface, agentLabel: context.usageAgent } : void 0;
|
|
13151
|
+
}
|
|
13127
13152
|
function requestAuditIdentity(request) {
|
|
13128
13153
|
const principal = request.principal;
|
|
13129
13154
|
if (!principal) return {};
|
|
@@ -13152,17 +13177,30 @@ function registerRequestContext(app, options = {}) {
|
|
|
13152
13177
|
actorSession: sanitizeRequestContext(request.headers["x-canonry-actor-session"]) ?? void 0,
|
|
13153
13178
|
method: request.method,
|
|
13154
13179
|
// Route template only, never a raw URL, query string, or route parameter.
|
|
13155
|
-
route: request.routeOptions.url
|
|
13180
|
+
route: request.routeOptions.url,
|
|
13181
|
+
usageSurface: headerValue(request.headers[USAGE_TELEMETRY_HEADERS.surface]),
|
|
13182
|
+
usageAgent: headerValue(request.headers[USAGE_TELEMETRY_HEADERS.agent])
|
|
13156
13183
|
}, done);
|
|
13157
13184
|
});
|
|
13158
13185
|
app.addHook("preHandler", (request, _reply, done) => {
|
|
13159
13186
|
populateIdentity(request);
|
|
13160
13187
|
done();
|
|
13161
13188
|
});
|
|
13162
|
-
app.addHook("onError", (request, _reply,
|
|
13189
|
+
app.addHook("onError", (request, _reply, error, done) => {
|
|
13163
13190
|
populateIdentity(request);
|
|
13191
|
+
const code = error.code;
|
|
13192
|
+
const context = requestContext.getStore();
|
|
13193
|
+
if (context && typeof code === "string" && ERROR_CODE_PATTERN.test(code)) context.errorCode = code;
|
|
13164
13194
|
done();
|
|
13165
13195
|
});
|
|
13196
|
+
app.addHook("onSend", (_request, reply, payload, done) => {
|
|
13197
|
+
if (reply.statusCode >= 400) {
|
|
13198
|
+
const context = requestContext.getStore();
|
|
13199
|
+
const code = context ? errorCodeOfBody(payload) : void 0;
|
|
13200
|
+
if (context && code) context.errorCode = code;
|
|
13201
|
+
}
|
|
13202
|
+
done(null, payload);
|
|
13203
|
+
});
|
|
13166
13204
|
app.addHook("onResponse", (request, reply, done) => {
|
|
13167
13205
|
populateIdentity(request);
|
|
13168
13206
|
const context = requestContext.getStore();
|
|
@@ -13176,6 +13214,7 @@ function registerRequestContext(app, options = {}) {
|
|
|
13176
13214
|
route,
|
|
13177
13215
|
statusCode: reply.statusCode,
|
|
13178
13216
|
durationMs: reply.elapsedTime,
|
|
13217
|
+
...context?.errorCode && reply.statusCode >= 400 ? { errorCode: context.errorCode } : {},
|
|
13179
13218
|
userAgent: headerValue(request.headers["user-agent"]),
|
|
13180
13219
|
actorSession: headerValue(request.headers["x-canonry-actor-session"]),
|
|
13181
13220
|
principalKind: kind === "user" ? "user" : kind ? "api-key" : void 0,
|
|
@@ -14532,6 +14571,17 @@ async function sentimentRoutes(app, options) {
|
|
|
14532
14571
|
});
|
|
14533
14572
|
}
|
|
14534
14573
|
|
|
14574
|
+
// ../api-routes/src/outcome-telemetry.ts
|
|
14575
|
+
function createOutcomeEmitter(sink) {
|
|
14576
|
+
return (event) => {
|
|
14577
|
+
if (!sink) return;
|
|
14578
|
+
try {
|
|
14579
|
+
sink({ ...event, attribution: event.attribution ?? currentOutcomeAttribution() });
|
|
14580
|
+
} catch {
|
|
14581
|
+
}
|
|
14582
|
+
};
|
|
14583
|
+
}
|
|
14584
|
+
|
|
14535
14585
|
// ../api-routes/src/oauth.ts
|
|
14536
14586
|
import crypto6 from "crypto";
|
|
14537
14587
|
import { and as and5, eq as eq10, isNull as isNull3, lt as lt3 } from "drizzle-orm";
|
|
@@ -14995,6 +15045,93 @@ function registerOAuthAdminRoutes(app, opts) {
|
|
|
14995
15045
|
import crypto12 from "crypto";
|
|
14996
15046
|
import { and as and13, eq as eq19, sql as sql7 } from "drizzle-orm";
|
|
14997
15047
|
|
|
15048
|
+
// ../api-routes/src/feature-outcome.ts
|
|
15049
|
+
function reportFeatureOutcome(app, target, outcome2) {
|
|
15050
|
+
try {
|
|
15051
|
+
app.emitOutcome({
|
|
15052
|
+
event: "feature.completed",
|
|
15053
|
+
properties: {
|
|
15054
|
+
feature: target.feature,
|
|
15055
|
+
operation: target.operation,
|
|
15056
|
+
...target.trigger ? { trigger: target.trigger } : {},
|
|
15057
|
+
...outcome2
|
|
15058
|
+
},
|
|
15059
|
+
...target.attribution ? { attribution: target.attribution } : {}
|
|
15060
|
+
});
|
|
15061
|
+
} catch {
|
|
15062
|
+
}
|
|
15063
|
+
}
|
|
15064
|
+
function failedOutcome(err, reasonCode) {
|
|
15065
|
+
return { status: OutcomeStatuses.failed, ...classifyOutcomeError(err), ...reasonCode ? { reasonCode } : {} };
|
|
15066
|
+
}
|
|
15067
|
+
async function withFeatureOutcome(app, target, run, failureReason) {
|
|
15068
|
+
const startedAt = Date.now();
|
|
15069
|
+
let settled;
|
|
15070
|
+
const report = (outcome2) => {
|
|
15071
|
+
try {
|
|
15072
|
+
const resolved = outcome2();
|
|
15073
|
+
if (resolved) reportFeatureOutcome(app, target, { ...resolved, durationBucket: bucketDuration(Date.now() - startedAt) });
|
|
15074
|
+
} catch {
|
|
15075
|
+
}
|
|
15076
|
+
};
|
|
15077
|
+
let result;
|
|
15078
|
+
try {
|
|
15079
|
+
result = await run((outcome2) => {
|
|
15080
|
+
settled = outcome2;
|
|
15081
|
+
});
|
|
15082
|
+
} catch (err) {
|
|
15083
|
+
report(() => {
|
|
15084
|
+
if (settled === null) return null;
|
|
15085
|
+
if (settled && settled.status !== OutcomeStatuses.succeeded) return settled;
|
|
15086
|
+
return failedOutcome(err, failureReason?.(err));
|
|
15087
|
+
});
|
|
15088
|
+
throw err;
|
|
15089
|
+
}
|
|
15090
|
+
report(() => settled === void 0 ? { status: OutcomeStatuses.succeeded } : settled);
|
|
15091
|
+
return result;
|
|
15092
|
+
}
|
|
15093
|
+
function startRouteOutcome(app, feature, operation, options = {}) {
|
|
15094
|
+
const startedAt = Date.now();
|
|
15095
|
+
let sent = false;
|
|
15096
|
+
const report = (outcome2) => {
|
|
15097
|
+
if (sent) return;
|
|
15098
|
+
sent = true;
|
|
15099
|
+
const counts2 = Object.entries(outcome2.counts ?? {}).filter((entry) => typeof entry[1] === "number");
|
|
15100
|
+
reportFeatureOutcome(app, { feature, operation, ...options }, {
|
|
15101
|
+
status: outcome2.status,
|
|
15102
|
+
...outcome2.reasonCode ? { reasonCode: outcome2.reasonCode } : {},
|
|
15103
|
+
...outcome2.errorName ? { errorName: outcome2.errorName } : {},
|
|
15104
|
+
durationBucket: bucketDuration(Date.now() - startedAt),
|
|
15105
|
+
...counts2.length > 0 ? { counts: Object.fromEntries(counts2) } : {}
|
|
15106
|
+
});
|
|
15107
|
+
};
|
|
15108
|
+
return {
|
|
15109
|
+
report,
|
|
15110
|
+
refuse(reasonCode, error) {
|
|
15111
|
+
report({ status: OutcomeStatuses.failed, reasonCode });
|
|
15112
|
+
return error;
|
|
15113
|
+
},
|
|
15114
|
+
guard(reasonCode, check2) {
|
|
15115
|
+
try {
|
|
15116
|
+
return check2();
|
|
15117
|
+
} catch (err) {
|
|
15118
|
+
report({ status: OutcomeStatuses.failed, ...classifyOutcomeError(err), reasonCode });
|
|
15119
|
+
throw err;
|
|
15120
|
+
}
|
|
15121
|
+
}
|
|
15122
|
+
};
|
|
15123
|
+
}
|
|
15124
|
+
function upstreamFailure(err) {
|
|
15125
|
+
const classified = classifyOutcomeError(err);
|
|
15126
|
+
const upstreamStatus = err instanceof AppError ? err.details?.upstreamStatus : void 0;
|
|
15127
|
+
return typeof upstreamStatus === "number" ? { ...classified, reasonCode: classifyOutcomeError({ status: upstreamStatus }).reasonCode } : classified;
|
|
15128
|
+
}
|
|
15129
|
+
function credentialFailure(err) {
|
|
15130
|
+
const classified = classifyOutcomeError(err);
|
|
15131
|
+
const transient = [OutcomeReasonCodes.RATE_LIMITED, OutcomeReasonCodes.TIMEOUT, OutcomeReasonCodes.NETWORK];
|
|
15132
|
+
return transient.includes(classified.reasonCode) ? classified : { ...classified, reasonCode: OutcomeReasonCodes.INVALID_CREDENTIALS };
|
|
15133
|
+
}
|
|
15134
|
+
|
|
14998
15135
|
// ../api-routes/src/competitor-writes.ts
|
|
14999
15136
|
import crypto8 from "crypto";
|
|
15000
15137
|
import { and as and8, eq as eq13 } from "drizzle-orm";
|
|
@@ -15754,6 +15891,450 @@ function marketCompetitorDto(entry, stored, now) {
|
|
|
15754
15891
|
};
|
|
15755
15892
|
}
|
|
15756
15893
|
|
|
15894
|
+
// ../api-routes/src/notifications/destinations.ts
|
|
15895
|
+
import { z as z2 } from "zod";
|
|
15896
|
+
|
|
15897
|
+
// ../api-routes/src/notifications/alert.ts
|
|
15898
|
+
import { z } from "zod";
|
|
15899
|
+
var alertSeveritySchema = z.enum(["critical", "warning", "success", "info"]);
|
|
15900
|
+
var AlertSeverities = alertSeveritySchema.enum;
|
|
15901
|
+
var AlertFieldLabels = {
|
|
15902
|
+
status: "Status",
|
|
15903
|
+
check: "Check",
|
|
15904
|
+
previously: "Was",
|
|
15905
|
+
remediation: "What to do",
|
|
15906
|
+
alsoFailing: "Also failing",
|
|
15907
|
+
event: "Event",
|
|
15908
|
+
run: "Run",
|
|
15909
|
+
changes: "Changes",
|
|
15910
|
+
insights: "Insights",
|
|
15911
|
+
location: "Location",
|
|
15912
|
+
rating: "Rating",
|
|
15913
|
+
reviewer: "Reviewer",
|
|
15914
|
+
replied: "Replied",
|
|
15915
|
+
source: "Source"
|
|
15916
|
+
};
|
|
15917
|
+
var HEALTH_EVENTS = /* @__PURE__ */ new Set(["health.degraded", "health.recovered"]);
|
|
15918
|
+
function isHealth(payload) {
|
|
15919
|
+
return HEALTH_EVENTS.has(payload.event);
|
|
15920
|
+
}
|
|
15921
|
+
var MAX_LIST_ITEMS = 10;
|
|
15922
|
+
function bulletList(lines) {
|
|
15923
|
+
const shown = lines.slice(0, MAX_LIST_ITEMS);
|
|
15924
|
+
const omitted = lines.length - shown.length;
|
|
15925
|
+
const body = shown.map((line) => `\u2022 ${line}`).join("\n");
|
|
15926
|
+
return omitted > 0 ? `${body}
|
|
15927
|
+
\u2022 \u2026and ${omitted} more` : body;
|
|
15928
|
+
}
|
|
15929
|
+
function healthView(payload) {
|
|
15930
|
+
const { health, project } = payload;
|
|
15931
|
+
const recovered = payload.event === "health.recovered";
|
|
15932
|
+
const severity = recovered ? AlertSeverities.success : health.status === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
|
|
15933
|
+
const fields = [
|
|
15934
|
+
{ label: AlertFieldLabels.status, value: recovered ? "recovered" : health.status, compact: true },
|
|
15935
|
+
{ label: AlertFieldLabels.check, value: health.code, compact: true }
|
|
15936
|
+
];
|
|
15937
|
+
if (health.previousStatus) {
|
|
15938
|
+
fields.push({ label: AlertFieldLabels.previously, value: health.previousStatus, compact: true });
|
|
15939
|
+
}
|
|
15940
|
+
if (health.remediation) {
|
|
15941
|
+
fields.push({ label: AlertFieldLabels.remediation, value: health.remediation });
|
|
15942
|
+
}
|
|
15943
|
+
const others = health.failing.filter((check2) => check2.code !== health.code);
|
|
15944
|
+
if (others.length > 0) {
|
|
15945
|
+
fields.push({
|
|
15946
|
+
label: `${AlertFieldLabels.alsoFailing} (${others.length})`,
|
|
15947
|
+
value: bulletList(others.map((check2) => `${check2.status} \u2014 ${check2.code}`))
|
|
15948
|
+
});
|
|
15949
|
+
}
|
|
15950
|
+
const website = health.code.startsWith("site.reachability.");
|
|
15951
|
+
return {
|
|
15952
|
+
severity,
|
|
15953
|
+
title: website ? recovered ? `${project.name} website is back up` : `${project.name} website is down` : recovered ? `${project.name} recovered` : `${project.name} \u2014 measurement degraded`,
|
|
15954
|
+
body: health.summary,
|
|
15955
|
+
fields,
|
|
15956
|
+
url: payload.dashboardUrl,
|
|
15957
|
+
footer: project.canonicalDomain,
|
|
15958
|
+
timestamp: health.checkedAt
|
|
15959
|
+
};
|
|
15960
|
+
}
|
|
15961
|
+
function runView(payload) {
|
|
15962
|
+
const failed = payload.event === "run.failed";
|
|
15963
|
+
const transitions = "transitions" in payload ? payload.transitions : [];
|
|
15964
|
+
const insights2 = "insights" in payload ? payload.insights : [];
|
|
15965
|
+
const fields = [
|
|
15966
|
+
{ label: AlertFieldLabels.event, value: payload.event, compact: true },
|
|
15967
|
+
{ label: AlertFieldLabels.run, value: payload.run.status, compact: true }
|
|
15968
|
+
];
|
|
15969
|
+
if (transitions.length > 0) {
|
|
15970
|
+
fields.push({
|
|
15971
|
+
label: `${AlertFieldLabels.changes} (${transitions.length})`,
|
|
15972
|
+
value: bulletList(transitions.map((t) => `${t.provider}: ${t.from} \u2192 ${t.to} \u2014 ${t.query}`))
|
|
15973
|
+
});
|
|
15974
|
+
}
|
|
15975
|
+
if (insights2.length > 0) {
|
|
15976
|
+
fields.push({
|
|
15977
|
+
label: `${AlertFieldLabels.insights} (${insights2.length})`,
|
|
15978
|
+
value: bulletList(insights2.map((i) => `[${i.severity}] ${i.title}`))
|
|
15979
|
+
});
|
|
15980
|
+
}
|
|
15981
|
+
return {
|
|
15982
|
+
severity: failed ? AlertSeverities.critical : AlertSeverities.info,
|
|
15983
|
+
title: payload.project.name,
|
|
15984
|
+
fields,
|
|
15985
|
+
url: payload.dashboardUrl,
|
|
15986
|
+
footer: payload.project.canonicalDomain,
|
|
15987
|
+
...payload.run.finishedAt ? { timestamp: payload.run.finishedAt } : {}
|
|
15988
|
+
};
|
|
15989
|
+
}
|
|
15990
|
+
function stars(rating) {
|
|
15991
|
+
const filled = Math.max(0, Math.min(5, Math.round(rating)));
|
|
15992
|
+
return `${"\u2605".repeat(filled)}${"\u2606".repeat(5 - filled)}`;
|
|
15993
|
+
}
|
|
15994
|
+
function snippet(text2, max2 = 160) {
|
|
15995
|
+
const flat = text2.replace(/\s+/g, " ").trim();
|
|
15996
|
+
return flat.length > max2 ? `${flat.slice(0, max2 - 1)}\u2026` : flat;
|
|
15997
|
+
}
|
|
15998
|
+
var SOURCE_LABELS = {
|
|
15999
|
+
gbp: "Business Profile",
|
|
16000
|
+
places: "Public listing (Places, partial)"
|
|
16001
|
+
};
|
|
16002
|
+
function reviewView(payload) {
|
|
16003
|
+
const { project, reviews } = payload;
|
|
16004
|
+
const newest = reviews.reduce((latest, r) => !latest || r.updateTime > latest ? r.updateTime : latest, void 0);
|
|
16005
|
+
const origins = [...new Set(reviews.map((r) => r.origin))];
|
|
16006
|
+
const sourceField = {
|
|
16007
|
+
label: AlertFieldLabels.source,
|
|
16008
|
+
value: origins.map((origin) => SOURCE_LABELS[origin]).join(", "),
|
|
16009
|
+
compact: true
|
|
16010
|
+
};
|
|
16011
|
+
if (reviews.length === 1) {
|
|
16012
|
+
const review = reviews[0];
|
|
16013
|
+
const fields = [
|
|
16014
|
+
{ label: AlertFieldLabels.location, value: review.location.displayName, compact: true },
|
|
16015
|
+
{ label: AlertFieldLabels.rating, value: `${stars(review.starRating)} (${review.starRating}/5)`, compact: true }
|
|
16016
|
+
];
|
|
16017
|
+
if (review.reviewerName) fields.push({ label: AlertFieldLabels.reviewer, value: review.reviewerName, compact: true });
|
|
16018
|
+
if (review.replied !== null) fields.push({ label: AlertFieldLabels.replied, value: review.replied ? "yes" : "no", compact: true });
|
|
16019
|
+
fields.push(sourceField);
|
|
16020
|
+
return {
|
|
16021
|
+
severity: AlertSeverities.warning,
|
|
16022
|
+
title: `${project.name}: new ${review.starRating}-star review`,
|
|
16023
|
+
body: review.comment ?? "Rating only, no text.",
|
|
16024
|
+
fields,
|
|
16025
|
+
url: review.reviewUri ?? review.location.mapsUri ?? payload.dashboardUrl,
|
|
16026
|
+
footer: project.canonicalDomain,
|
|
16027
|
+
timestamp: review.updateTime
|
|
16028
|
+
};
|
|
16029
|
+
}
|
|
16030
|
+
return {
|
|
16031
|
+
severity: AlertSeverities.warning,
|
|
16032
|
+
title: `${project.name}: ${reviews.length} new negative reviews`,
|
|
16033
|
+
body: bulletList(reviews.map((r) => {
|
|
16034
|
+
const text2 = r.comment ? ` "${snippet(r.comment)}"` : "";
|
|
16035
|
+
const by = r.reviewerName ? ` (${r.reviewerName})` : "";
|
|
16036
|
+
return `${stars(r.starRating)} ${r.location.displayName}:${text2}${by}`;
|
|
16037
|
+
})),
|
|
16038
|
+
fields: [sourceField],
|
|
16039
|
+
url: payload.dashboardUrl,
|
|
16040
|
+
footer: project.canonicalDomain,
|
|
16041
|
+
...newest ? { timestamp: newest } : {}
|
|
16042
|
+
};
|
|
16043
|
+
}
|
|
16044
|
+
function ratingView(payload) {
|
|
16045
|
+
const { project, ratings } = payload;
|
|
16046
|
+
const line = (r) => {
|
|
16047
|
+
const counts2 = r.previousReviewCount !== null && r.reviewCount !== null ? ` (${r.previousReviewCount} \u2192 ${r.reviewCount} reviews)` : "";
|
|
16048
|
+
return `${r.location.displayName}: ${r.previousRating} \u2192 ${r.rating}${counts2}`;
|
|
16049
|
+
};
|
|
16050
|
+
const single = ratings.length === 1 ? ratings[0] : null;
|
|
16051
|
+
return {
|
|
16052
|
+
severity: AlertSeverities.warning,
|
|
16053
|
+
title: single ? `${project.name}: Google rating fell to ${single.rating}` : `${project.name}: Google rating fell at ${ratings.length} locations`,
|
|
16054
|
+
body: single ? line(single) : bulletList(ratings.map(line)),
|
|
16055
|
+
fields: [
|
|
16056
|
+
{ label: AlertFieldLabels.source, value: SOURCE_LABELS.places, compact: true },
|
|
16057
|
+
{
|
|
16058
|
+
label: AlertFieldLabels.remediation,
|
|
16059
|
+
value: "Read the newest reviews on Google Maps. Without Business Profile review access, Canonry sees at most five of them."
|
|
16060
|
+
}
|
|
16061
|
+
],
|
|
16062
|
+
url: single?.location.mapsUri ?? payload.dashboardUrl,
|
|
16063
|
+
footer: project.canonicalDomain,
|
|
16064
|
+
timestamp: ratings.reduce((latest, r) => r.observedAt > latest ? r.observedAt : latest, ratings[0]?.observedAt ?? "")
|
|
16065
|
+
};
|
|
16066
|
+
}
|
|
16067
|
+
function toAlertView(payload) {
|
|
16068
|
+
if (isHealth(payload)) return healthView(payload);
|
|
16069
|
+
if (payload.event === "review.negative") return reviewView(payload);
|
|
16070
|
+
if (payload.event === "review.rating-dropped") return ratingView(payload);
|
|
16071
|
+
return runView(payload);
|
|
16072
|
+
}
|
|
16073
|
+
|
|
16074
|
+
// ../api-routes/src/notifications/destinations.ts
|
|
16075
|
+
var webhookDestinationSchema = z2.enum(["first-party", "discord", "slack"]);
|
|
16076
|
+
var WebhookDestinations = webhookDestinationSchema.enum;
|
|
16077
|
+
function hostIs(url, ...domains) {
|
|
16078
|
+
const host = url.hostname.toLowerCase();
|
|
16079
|
+
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
16080
|
+
}
|
|
16081
|
+
var DISCORD_MAX_DESCRIPTION = 3800;
|
|
16082
|
+
var DISCORD_MAX_FIELD = 1e3;
|
|
16083
|
+
var DISCORD_COLORS = {
|
|
16084
|
+
[AlertSeverities.critical]: 14235195,
|
|
16085
|
+
[AlertSeverities.warning]: 14256926,
|
|
16086
|
+
[AlertSeverities.success]: 3055183,
|
|
16087
|
+
[AlertSeverities.info]: 4873068
|
|
16088
|
+
};
|
|
16089
|
+
var SEVERITY_ICONS = {
|
|
16090
|
+
[AlertSeverities.critical]: "\u{1F534}",
|
|
16091
|
+
[AlertSeverities.warning]: "\u{1F7E0}",
|
|
16092
|
+
[AlertSeverities.success]: "\u2705",
|
|
16093
|
+
[AlertSeverities.info]: "\u{1F4CA}"
|
|
16094
|
+
};
|
|
16095
|
+
function clamp(value, max2) {
|
|
16096
|
+
return value.length <= max2 ? value : `${value.slice(0, max2 - 1)}\u2026`;
|
|
16097
|
+
}
|
|
16098
|
+
function absoluteLink(url) {
|
|
16099
|
+
if (!url) return void 0;
|
|
16100
|
+
try {
|
|
16101
|
+
const parsed = new URL(url);
|
|
16102
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : void 0;
|
|
16103
|
+
} catch {
|
|
16104
|
+
return void 0;
|
|
16105
|
+
}
|
|
16106
|
+
}
|
|
16107
|
+
function renderDiscord(view) {
|
|
16108
|
+
return {
|
|
16109
|
+
username: "Canonry",
|
|
16110
|
+
embeds: [{
|
|
16111
|
+
title: `${SEVERITY_ICONS[view.severity]} ${view.title}`,
|
|
16112
|
+
...view.body ? { description: clamp(view.body, DISCORD_MAX_DESCRIPTION) } : {},
|
|
16113
|
+
color: DISCORD_COLORS[view.severity],
|
|
16114
|
+
fields: view.fields.map((field) => ({
|
|
16115
|
+
name: field.label,
|
|
16116
|
+
value: clamp(field.value, DISCORD_MAX_FIELD),
|
|
16117
|
+
...field.compact ? { inline: true } : {}
|
|
16118
|
+
})),
|
|
16119
|
+
...absoluteLink(view.url) ? { url: absoluteLink(view.url) } : {},
|
|
16120
|
+
...view.timestamp ? { timestamp: view.timestamp } : {},
|
|
16121
|
+
...view.footer ? { footer: { text: view.footer } } : {}
|
|
16122
|
+
}]
|
|
16123
|
+
};
|
|
16124
|
+
}
|
|
16125
|
+
var SLACK_MAX_TEXT = 2900;
|
|
16126
|
+
var SLACK_MAX_FIELD = 1e3;
|
|
16127
|
+
var SLACK_COLORS = {
|
|
16128
|
+
[AlertSeverities.critical]: "#d9363b",
|
|
16129
|
+
[AlertSeverities.warning]: "#d98b1e",
|
|
16130
|
+
[AlertSeverities.success]: "#2e9e4f",
|
|
16131
|
+
[AlertSeverities.info]: "#4a5b6c"
|
|
16132
|
+
};
|
|
16133
|
+
function renderSlack(view) {
|
|
16134
|
+
const headline = `${SEVERITY_ICONS[view.severity]} ${view.title}`;
|
|
16135
|
+
return {
|
|
16136
|
+
// `text` is the notification preview and the accessible fallback, so it
|
|
16137
|
+
// must stand alone rather than repeat an empty string.
|
|
16138
|
+
text: headline,
|
|
16139
|
+
attachments: [{
|
|
16140
|
+
color: SLACK_COLORS[view.severity],
|
|
16141
|
+
title: headline,
|
|
16142
|
+
...absoluteLink(view.url) ? { title_link: absoluteLink(view.url) } : {},
|
|
16143
|
+
...view.body ? { text: clamp(view.body, SLACK_MAX_TEXT) } : {},
|
|
16144
|
+
fields: view.fields.map((field) => ({
|
|
16145
|
+
title: field.label,
|
|
16146
|
+
value: clamp(field.value, SLACK_MAX_FIELD),
|
|
16147
|
+
...field.compact ? { short: true } : {}
|
|
16148
|
+
})),
|
|
16149
|
+
...view.footer ? { footer: view.footer } : {},
|
|
16150
|
+
...view.timestamp ? { ts: Math.floor(new Date(view.timestamp).getTime() / 1e3) } : {}
|
|
16151
|
+
}]
|
|
16152
|
+
};
|
|
16153
|
+
}
|
|
16154
|
+
var DISCORD_ADAPTER = {
|
|
16155
|
+
destination: WebhookDestinations.discord,
|
|
16156
|
+
matches: (url) => hostIs(url, "discord.com", "discordapp.com") && url.pathname.startsWith("/api/webhooks/"),
|
|
16157
|
+
render: renderDiscord,
|
|
16158
|
+
signed: false
|
|
16159
|
+
};
|
|
16160
|
+
var SLACK_ADAPTER = {
|
|
16161
|
+
destination: WebhookDestinations.slack,
|
|
16162
|
+
matches: (url) => hostIs(url, "slack.com") && url.pathname.startsWith("/services/"),
|
|
16163
|
+
render: renderSlack,
|
|
16164
|
+
signed: false
|
|
16165
|
+
};
|
|
16166
|
+
var FIRST_PARTY_ADAPTER = {
|
|
16167
|
+
destination: WebhookDestinations["first-party"],
|
|
16168
|
+
matches: () => true,
|
|
16169
|
+
signed: true
|
|
16170
|
+
};
|
|
16171
|
+
var DESTINATION_ADAPTERS = [
|
|
16172
|
+
DISCORD_ADAPTER,
|
|
16173
|
+
SLACK_ADAPTER,
|
|
16174
|
+
FIRST_PARTY_ADAPTER
|
|
16175
|
+
];
|
|
16176
|
+
function resolveDestination(rawUrl) {
|
|
16177
|
+
let url;
|
|
16178
|
+
try {
|
|
16179
|
+
url = new URL(rawUrl);
|
|
16180
|
+
} catch {
|
|
16181
|
+
return FIRST_PARTY_ADAPTER;
|
|
16182
|
+
}
|
|
16183
|
+
if (url.protocol !== "https:") return FIRST_PARTY_ADAPTER;
|
|
16184
|
+
return DESTINATION_ADAPTERS.find((adapter) => adapter.matches(url)) ?? FIRST_PARTY_ADAPTER;
|
|
16185
|
+
}
|
|
16186
|
+
|
|
16187
|
+
// ../api-routes/src/connection-telemetry.ts
|
|
16188
|
+
var PROVIDER_NAME_SET = new Set(PROVIDER_NAMES);
|
|
16189
|
+
function outcomeProviderName(name) {
|
|
16190
|
+
return PROVIDER_NAME_SET.has(name) ? name : void 0;
|
|
16191
|
+
}
|
|
16192
|
+
function telemetryRead(read, fallback) {
|
|
16193
|
+
try {
|
|
16194
|
+
return read();
|
|
16195
|
+
} catch {
|
|
16196
|
+
return fallback;
|
|
16197
|
+
}
|
|
16198
|
+
}
|
|
16199
|
+
var APP_ERROR_REASONS = {
|
|
16200
|
+
NOT_IMPLEMENTED: "UNSUPPORTED",
|
|
16201
|
+
INTERNAL_ERROR: "INTERNAL",
|
|
16202
|
+
MISSING_DEPENDENCY: "UNSUPPORTED"
|
|
16203
|
+
};
|
|
16204
|
+
function routeOutcomeFailure(err, reasonCode) {
|
|
16205
|
+
const classified = classifyOutcomeError(err);
|
|
16206
|
+
if (reasonCode) return { ...classified, reasonCode };
|
|
16207
|
+
const { code, details } = err && typeof err === "object" ? err : {};
|
|
16208
|
+
if (code === "FORBIDDEN") {
|
|
16209
|
+
const upstreamStatus = details?.upstreamStatus;
|
|
16210
|
+
return {
|
|
16211
|
+
...classified,
|
|
16212
|
+
reasonCode: typeof upstreamStatus === "number" ? classifyOutcomeError({ status: upstreamStatus }).reasonCode : "GATE_REFUSED"
|
|
16213
|
+
};
|
|
16214
|
+
}
|
|
16215
|
+
const mapped = typeof code === "string" ? APP_ERROR_REASONS[code] : void 0;
|
|
16216
|
+
return { ...classified, reasonCode: mapped ?? classified.reasonCode };
|
|
16217
|
+
}
|
|
16218
|
+
var DESTINATION_TARGETS = {
|
|
16219
|
+
"first-party": "first_party",
|
|
16220
|
+
discord: "discord",
|
|
16221
|
+
slack: "slack"
|
|
16222
|
+
};
|
|
16223
|
+
function webhookOutcomeTarget(rawUrl) {
|
|
16224
|
+
return DESTINATION_TARGETS[resolveDestination(rawUrl).destination];
|
|
16225
|
+
}
|
|
16226
|
+
function webhookTargetRefusalReason(check2) {
|
|
16227
|
+
if (check2.blocked) return "BLOCKED_UNSAFE_URL";
|
|
16228
|
+
if (check2.unresolved) return "NETWORK";
|
|
16229
|
+
return "VALIDATION";
|
|
16230
|
+
}
|
|
16231
|
+
function webhookResponseReason(result) {
|
|
16232
|
+
if (result.status >= 200 && result.status < 300) return void 0;
|
|
16233
|
+
if (result.status === 0) return result.timedOut ? "TIMEOUT" : "NETWORK";
|
|
16234
|
+
if (result.status >= 500) return "HTTP_5XX";
|
|
16235
|
+
if (result.status >= 400) return "HTTP_4XX";
|
|
16236
|
+
return "UNSUPPORTED";
|
|
16237
|
+
}
|
|
16238
|
+
function oauthStartAttribution() {
|
|
16239
|
+
const context = getRequestContext();
|
|
16240
|
+
const surface = outcomeSurfaceSchema.safeParse(context?.usageSurface);
|
|
16241
|
+
const agent = normalizeAgentSlug(context?.usageAgent);
|
|
16242
|
+
return {
|
|
16243
|
+
...surface.success ? { surface: surface.data } : {},
|
|
16244
|
+
...agent ? { agent } : {}
|
|
16245
|
+
};
|
|
16246
|
+
}
|
|
16247
|
+
function oauthStateAttribution(state) {
|
|
16248
|
+
if (!state) return {};
|
|
16249
|
+
const surface = outcomeSurfaceSchema.safeParse(state.surface);
|
|
16250
|
+
const agent = typeof state.agent === "string" ? normalizeAgentSlug(state.agent) : null;
|
|
16251
|
+
return {
|
|
16252
|
+
...surface.success ? { surface: surface.data } : {},
|
|
16253
|
+
...agent ? { agent } : {}
|
|
16254
|
+
};
|
|
16255
|
+
}
|
|
16256
|
+
function unverifiedOAuthStateField(encoded, field) {
|
|
16257
|
+
if (!encoded || encoded.length > 8192) return void 0;
|
|
16258
|
+
try {
|
|
16259
|
+
const envelope = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
16260
|
+
if (typeof envelope.payload !== "string") return void 0;
|
|
16261
|
+
const value = JSON.parse(envelope.payload)[field];
|
|
16262
|
+
return typeof value === "string" ? value : void 0;
|
|
16263
|
+
} catch {
|
|
16264
|
+
return void 0;
|
|
16265
|
+
}
|
|
16266
|
+
}
|
|
16267
|
+
var NO_ATTEMPT = {
|
|
16268
|
+
update: () => {
|
|
16269
|
+
},
|
|
16270
|
+
started: () => {
|
|
16271
|
+
},
|
|
16272
|
+
succeeded: () => {
|
|
16273
|
+
},
|
|
16274
|
+
failed: () => {
|
|
16275
|
+
},
|
|
16276
|
+
cancelled: () => {
|
|
16277
|
+
},
|
|
16278
|
+
deferred: () => {
|
|
16279
|
+
}
|
|
16280
|
+
};
|
|
16281
|
+
function startConnectionAttempt(app, subject, now = Date.now) {
|
|
16282
|
+
if (!subject) return NO_ATTEMPT;
|
|
16283
|
+
const startedAt = now();
|
|
16284
|
+
let current = { ...subject };
|
|
16285
|
+
let reported = false;
|
|
16286
|
+
const report = (status, failure) => {
|
|
16287
|
+
if (reported) return;
|
|
16288
|
+
reported = true;
|
|
16289
|
+
try {
|
|
16290
|
+
const emit = app.emitOutcome;
|
|
16291
|
+
if (typeof emit !== "function") return;
|
|
16292
|
+
const properties = {
|
|
16293
|
+
...current,
|
|
16294
|
+
status,
|
|
16295
|
+
...failure,
|
|
16296
|
+
...status === "started" ? {} : { durationBucket: bucketDuration(now() - startedAt) }
|
|
16297
|
+
};
|
|
16298
|
+
for (const key of Object.keys(properties)) {
|
|
16299
|
+
if (properties[key] === void 0) delete properties[key];
|
|
16300
|
+
}
|
|
16301
|
+
emit.call(app, { event: "integration.connection", properties });
|
|
16302
|
+
} catch {
|
|
16303
|
+
}
|
|
16304
|
+
};
|
|
16305
|
+
return {
|
|
16306
|
+
update: (patch) => {
|
|
16307
|
+
current = { ...current, ...patch };
|
|
16308
|
+
},
|
|
16309
|
+
started: () => report("started"),
|
|
16310
|
+
succeeded: () => report("succeeded"),
|
|
16311
|
+
failed: (err, reasonCode) => report("failed", routeOutcomeFailure(err, reasonCode)),
|
|
16312
|
+
cancelled: (reasonCode) => report("cancelled", { reasonCode }),
|
|
16313
|
+
deferred: () => {
|
|
16314
|
+
reported = true;
|
|
16315
|
+
}
|
|
16316
|
+
};
|
|
16317
|
+
}
|
|
16318
|
+
function connectionRoute(app, subjectOf, handler) {
|
|
16319
|
+
return async (request, reply) => {
|
|
16320
|
+
let subject;
|
|
16321
|
+
try {
|
|
16322
|
+
subject = subjectOf(request);
|
|
16323
|
+
} catch {
|
|
16324
|
+
subject = void 0;
|
|
16325
|
+
}
|
|
16326
|
+
const attempt = startConnectionAttempt(app, subject);
|
|
16327
|
+
try {
|
|
16328
|
+
const result = await handler(request, reply, attempt);
|
|
16329
|
+
attempt.succeeded();
|
|
16330
|
+
return result;
|
|
16331
|
+
} catch (err) {
|
|
16332
|
+
attempt.failed(err);
|
|
16333
|
+
throw err;
|
|
16334
|
+
}
|
|
16335
|
+
};
|
|
16336
|
+
}
|
|
16337
|
+
|
|
15757
16338
|
// ../api-routes/src/settings.ts
|
|
15758
16339
|
var SETTINGS_WRITE_SCOPE = "settings.write";
|
|
15759
16340
|
async function settingsRoutes(app, opts) {
|
|
@@ -15782,7 +16363,13 @@ async function settingsRoutes(app, opts) {
|
|
|
15782
16363
|
bing: opts.bing ?? { configured: false }
|
|
15783
16364
|
};
|
|
15784
16365
|
});
|
|
15785
|
-
app.put("/settings/providers/:name",
|
|
16366
|
+
app.put("/settings/providers/:name", connectionRoute(app, (request) => {
|
|
16367
|
+
const provider = outcomeProviderName(request.params.name);
|
|
16368
|
+
const body = request.body;
|
|
16369
|
+
if (!provider || body?.apiKey === void 0 && body?.baseUrl === void 0) return void 0;
|
|
16370
|
+
const configured = (opts.providerSummary ?? []).some((entry) => entry.name === provider && entry.configured);
|
|
16371
|
+
return { integration: "provider", provider, action: configured ? "reauth" : "connect" };
|
|
16372
|
+
}, async (request) => {
|
|
15786
16373
|
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
15787
16374
|
const { apiKey, baseUrl, model, quota } = request.body ?? {};
|
|
15788
16375
|
const name = request.params.name;
|
|
@@ -15862,8 +16449,8 @@ async function settingsRoutes(app, opts) {
|
|
|
15862
16449
|
throw internalError("Failed to update provider configuration");
|
|
15863
16450
|
}
|
|
15864
16451
|
return result;
|
|
15865
|
-
});
|
|
15866
|
-
app.post("/settings/providers/reload", async (request) => {
|
|
16452
|
+
}));
|
|
16453
|
+
app.post("/settings/providers/reload", async (request) => withFeatureOutcome(app, { feature: "providers", operation: "reload" }, async (settle) => {
|
|
15867
16454
|
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
15868
16455
|
requireInstanceAdministrator(request);
|
|
15869
16456
|
const parsed = providerReloadRequestSchema.safeParse(request.body ?? {});
|
|
@@ -15879,8 +16466,9 @@ async function settingsRoutes(app, opts) {
|
|
|
15879
16466
|
entityType: "provider"
|
|
15880
16467
|
});
|
|
15881
16468
|
const providers = await opts.onProviderReload(parsed.data, { actor, userAgent, actorSession, requestId, credentialId });
|
|
16469
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { providers: providers.filter((provider) => provider.configured).length } });
|
|
15882
16470
|
return providerReloadResponseDtoSchema.parse({ reloaded: true, providers });
|
|
15883
|
-
});
|
|
16471
|
+
}, (err) => err instanceof AppError && err.code === "NOT_IMPLEMENTED" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
|
|
15884
16472
|
app.put("/settings/google", async (request) => {
|
|
15885
16473
|
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
15886
16474
|
const { clientId, clientSecret } = request.body ?? {};
|
|
@@ -18386,7 +18974,7 @@ async function projectRoutes(app, opts) {
|
|
|
18386
18974
|
});
|
|
18387
18975
|
return reply.send({ defaultLocation: label });
|
|
18388
18976
|
});
|
|
18389
|
-
app.get("/projects/:name/export", async (request, reply) => {
|
|
18977
|
+
app.get("/projects/:name/export", async (request, reply) => withFeatureOutcome(app, { feature: "exports", operation: "export" }, async (settle) => {
|
|
18390
18978
|
const project = resolveProject(app.db, request.params.name);
|
|
18391
18979
|
const negativeReviewMaxStars = readNegativeReviewMaxStars(app.db, project.id);
|
|
18392
18980
|
const qs = app.db.select().from(queries).where(eq19(queries.projectId, project.id)).all();
|
|
@@ -18450,8 +19038,9 @@ async function projectRoutes(app, opts) {
|
|
|
18450
19038
|
} : {}
|
|
18451
19039
|
}
|
|
18452
19040
|
};
|
|
19041
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { queries: qs.length } });
|
|
18453
19042
|
return reply.send(config);
|
|
18454
|
-
});
|
|
19043
|
+
}));
|
|
18455
19044
|
}
|
|
18456
19045
|
function assertProviderModelScope(request, current, next, nextProviders) {
|
|
18457
19046
|
const effectiveCurrent = pruneProviderModelsForProviders(current, nextProviders);
|
|
@@ -25707,9 +26296,12 @@ async function measurementDraftRoutes(app, opts = {}) {
|
|
|
25707
26296
|
diff: diffCompiledPlans(comparable, compiled.plan, active?.revision ?? null)
|
|
25708
26297
|
};
|
|
25709
26298
|
});
|
|
25710
|
-
app.post("/projects/:name/measurement-plan/draft/actions/publish", async (request, reply) => {
|
|
26299
|
+
app.post("/projects/:name/measurement-plan/draft/actions/publish", async (request, reply) => withFeatureOutcome(app, { feature: "measurement", operation: "publish" }, async (recordOutcome) => {
|
|
25711
26300
|
const gate = beginMutation(request, reply, "publish");
|
|
25712
|
-
if (gate.replay !== null)
|
|
26301
|
+
if (gate.replay !== null) {
|
|
26302
|
+
recordOutcome(null);
|
|
26303
|
+
return gate.replay;
|
|
26304
|
+
}
|
|
25713
26305
|
const parsed = measurementDraftPublishRequestSchema.safeParse(request.body);
|
|
25714
26306
|
if (!parsed.success) throw validationError('Invalid "publish" payload', { issues: parsed.error.issues });
|
|
25715
26307
|
const ifMatch = requireIfMatch(request);
|
|
@@ -25718,7 +26310,7 @@ async function measurementDraftRoutes(app, opts = {}) {
|
|
|
25718
26310
|
let pinEffect = null;
|
|
25719
26311
|
const settled = app.db.transaction((tx) => {
|
|
25720
26312
|
const pinWrite = beginMarketPinWrite(tx, gate.project);
|
|
25721
|
-
const settle = (published, version,
|
|
26313
|
+
const settle = (published, version, plan2) => {
|
|
25722
26314
|
const response = {
|
|
25723
26315
|
published,
|
|
25724
26316
|
active: {
|
|
@@ -25726,7 +26318,7 @@ async function measurementDraftRoutes(app, opts = {}) {
|
|
|
25726
26318
|
checksum: version.checksum,
|
|
25727
26319
|
compiledChecksum: version.compiledChecksum,
|
|
25728
26320
|
createdAt: version.createdAt,
|
|
25729
|
-
plan
|
|
26321
|
+
plan: plan2
|
|
25730
26322
|
}
|
|
25731
26323
|
};
|
|
25732
26324
|
sweepExpiredMeasurementReceipts(tx, now);
|
|
@@ -25843,9 +26435,11 @@ async function measurementDraftRoutes(app, opts = {}) {
|
|
|
25843
26435
|
const created = tx.select().from(measurementPlanVersions).where(eq31(measurementPlanVersions.id, versionId)).get();
|
|
25844
26436
|
return settle(true, created, compiled.plan);
|
|
25845
26437
|
});
|
|
26438
|
+
const { plan } = settled.active;
|
|
26439
|
+
recordOutcome(settled.published ? { status: OutcomeStatuses.succeeded, counts: { items: plan.groups.length, targets: plan.targets.length, queries: plan.querySnapshots.length } } : { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
|
|
25846
26440
|
notifyMarketPinWrite(opts, gate.project, pinEffect);
|
|
25847
26441
|
return settled;
|
|
25848
|
-
});
|
|
26442
|
+
}));
|
|
25849
26443
|
app.post("/projects/:name/measurement-plan/draft/actions/discard", async (request, reply) => {
|
|
25850
26444
|
const gate = beginMutation(request, reply, "discard");
|
|
25851
26445
|
if (gate.replay !== null) return gate.replay;
|
|
@@ -31773,7 +32367,7 @@ async function resolveWebhookTarget(raw, options = {}) {
|
|
|
31773
32367
|
}
|
|
31774
32368
|
const blocked = addresses.find((entry) => isBlockedAddress(entry.address, options));
|
|
31775
32369
|
if (blocked) {
|
|
31776
|
-
return { ok: false, message: '"url" must not resolve to a private or loopback address' };
|
|
32370
|
+
return { ok: false, message: '"url" must not resolve to a private or loopback address', blocked: true };
|
|
31777
32371
|
}
|
|
31778
32372
|
return {
|
|
31779
32373
|
ok: true,
|
|
@@ -31825,11 +32419,13 @@ async function deliverWebhook(target, payload, webhookSecret) {
|
|
|
31825
32419
|
resolve({ status: response.statusCode ?? 0, error: null });
|
|
31826
32420
|
});
|
|
31827
32421
|
});
|
|
32422
|
+
let timedOut = false;
|
|
31828
32423
|
request.on("timeout", () => {
|
|
32424
|
+
timedOut = true;
|
|
31829
32425
|
request.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
31830
32426
|
});
|
|
31831
32427
|
request.on("error", (error) => {
|
|
31832
|
-
resolve({ status: 0, error: error.message });
|
|
32428
|
+
resolve({ status: 0, error: error.message, ...timedOut ? { timedOut: true } : {} });
|
|
31833
32429
|
});
|
|
31834
32430
|
request.end(body);
|
|
31835
32431
|
});
|
|
@@ -36537,7 +37133,7 @@ function filenameFor(projectName, format2) {
|
|
|
36537
37133
|
return `canonry-results-${safeName}-${date}.${format2}`;
|
|
36538
37134
|
}
|
|
36539
37135
|
async function resultsExportRoutes(app) {
|
|
36540
|
-
app.get("/projects/:name/results/export", async (request, reply) => {
|
|
37136
|
+
app.get("/projects/:name/results/export", async (request, reply) => withFeatureOutcome(app, { feature: "exports", operation: "export" }, async (settle) => {
|
|
36541
37137
|
const project = resolveProject(app.db, request.params.name);
|
|
36542
37138
|
const format2 = parseFormat(request.query.format);
|
|
36543
37139
|
const includeProbes = parseBoolean(request.query.includeProbes, "includeProbes");
|
|
@@ -36634,13 +37230,11 @@ async function resultsExportRoutes(app) {
|
|
|
36634
37230
|
reply.header("Content-Disposition", `attachment; filename="${filenameFor(project.name, format2)}"`);
|
|
36635
37231
|
reply.header("Cache-Control", "private, no-store");
|
|
36636
37232
|
reply.header("X-Content-Type-Options", "nosniff");
|
|
36637
|
-
|
|
36638
|
-
|
|
36639
|
-
|
|
36640
|
-
|
|
36641
|
-
|
|
36642
|
-
return reply.send(dto);
|
|
36643
|
-
});
|
|
37233
|
+
const body = format2 === "csv" ? renderResultsExportCsv(dto) : JSON.stringify(dto);
|
|
37234
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { rows: records.length, bytes: Buffer.byteLength(body) } });
|
|
37235
|
+
reply.type(format2 === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8");
|
|
37236
|
+
return reply.send(body);
|
|
37237
|
+
}));
|
|
36644
37238
|
}
|
|
36645
37239
|
|
|
36646
37240
|
// ../api-routes/src/composites.ts
|
|
@@ -38135,6 +38729,16 @@ function findRecommendationByRef(db, project, targetRef) {
|
|
|
38135
38729
|
const rows = buildContentTargetRows(input);
|
|
38136
38730
|
return rows.find((r) => r.targetRef === targetRef) ?? null;
|
|
38137
38731
|
}
|
|
38732
|
+
function contentFailureReason(err) {
|
|
38733
|
+
return err instanceof AppError && err.code === "PROVIDER_ERROR" ? OutcomeReasonCodes.NOT_CONNECTED : void 0;
|
|
38734
|
+
}
|
|
38735
|
+
function generatedCounts(unit, costMillicents) {
|
|
38736
|
+
return {
|
|
38737
|
+
[unit]: 1,
|
|
38738
|
+
modelCalls: 1,
|
|
38739
|
+
...Number.isInteger(costMillicents) && costMillicents >= 0 ? { costMicros: costMillicents * 10 } : {}
|
|
38740
|
+
};
|
|
38741
|
+
}
|
|
38138
38742
|
async function contentRoutes(app, opts = {}) {
|
|
38139
38743
|
app.get("/projects/:name/content/targets", async (request) => {
|
|
38140
38744
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -38251,7 +38855,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38251
38855
|
}
|
|
38252
38856
|
return reply.send(formatExplanationRow(row));
|
|
38253
38857
|
});
|
|
38254
|
-
app.post("/projects/:name/content/recommendations/:targetRef/analyze", async (request, reply) => {
|
|
38858
|
+
app.post("/projects/:name/content/recommendations/:targetRef/analyze", async (request, reply) => withFeatureOutcome(app, { feature: "content", operation: "analyze" }, async (settle) => {
|
|
38255
38859
|
const project = resolveProject(app.db, request.params.name);
|
|
38256
38860
|
const explainer = opts.explainContentRecommendation;
|
|
38257
38861
|
if (!explainer) {
|
|
@@ -38274,7 +38878,10 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38274
38878
|
eq52(recommendationExplanations.projectId, project.id),
|
|
38275
38879
|
eq52(recommendationExplanations.targetRef, targetRef)
|
|
38276
38880
|
)).orderBy(desc26(recommendationExplanations.generatedAt)).limit(1).get();
|
|
38277
|
-
if (cached)
|
|
38881
|
+
if (cached) {
|
|
38882
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { targets: 1 } });
|
|
38883
|
+
return reply.send(formatExplanationRow(cached));
|
|
38884
|
+
}
|
|
38278
38885
|
}
|
|
38279
38886
|
const result = await explainer({
|
|
38280
38887
|
projectId: project.id,
|
|
@@ -38309,6 +38916,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38309
38916
|
generatedAt: now
|
|
38310
38917
|
}
|
|
38311
38918
|
}).run();
|
|
38919
|
+
settle({ status: OutcomeStatuses.succeeded, counts: generatedCounts("targets", result.costMillicents) });
|
|
38312
38920
|
const row = app.db.select().from(recommendationExplanations).where(and45(
|
|
38313
38921
|
eq52(recommendationExplanations.projectId, project.id),
|
|
38314
38922
|
eq52(recommendationExplanations.targetRef, targetRef),
|
|
@@ -38316,7 +38924,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38316
38924
|
)).get();
|
|
38317
38925
|
if (!row) throw notFound("recommendationExplanation", targetRef);
|
|
38318
38926
|
return reply.send(formatExplanationRow(row));
|
|
38319
|
-
});
|
|
38927
|
+
}, contentFailureReason));
|
|
38320
38928
|
app.get("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
|
|
38321
38929
|
const project = resolveProject(app.db, request.params.name);
|
|
38322
38930
|
const { targetRef } = request.params;
|
|
@@ -38333,7 +38941,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38333
38941
|
if (!row) throw notFound("recommendationBrief", request.params.targetRef);
|
|
38334
38942
|
return reply.send(formatBriefRow(row));
|
|
38335
38943
|
});
|
|
38336
|
-
app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
|
|
38944
|
+
app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => withFeatureOutcome(app, { feature: "content", operation: "brief" }, async (settle) => {
|
|
38337
38945
|
const project = resolveProject(app.db, request.params.name);
|
|
38338
38946
|
const synthesizer = opts.briefContentRecommendation;
|
|
38339
38947
|
if (!synthesizer) {
|
|
@@ -38352,13 +38960,17 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38352
38960
|
throw notFound("contentRecommendation", targetRef);
|
|
38353
38961
|
}
|
|
38354
38962
|
if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
|
|
38963
|
+
settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.GATE_REFUSED });
|
|
38355
38964
|
throw validationError(
|
|
38356
38965
|
`Cannot synthesize a brief for "${recommendation.query}": its cited surface is ceded (dominated by aggregators/editorial). This is not a query first-party content can realistically win.`
|
|
38357
38966
|
);
|
|
38358
38967
|
}
|
|
38359
38968
|
if (!body.forceRefresh) {
|
|
38360
38969
|
const cached = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
|
|
38361
|
-
if (cached)
|
|
38970
|
+
if (cached) {
|
|
38971
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { briefs: 1 } });
|
|
38972
|
+
return reply.send(formatBriefRow(cached));
|
|
38973
|
+
}
|
|
38362
38974
|
}
|
|
38363
38975
|
const result = await synthesizer({
|
|
38364
38976
|
projectId: project.id,
|
|
@@ -38393,6 +39005,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38393
39005
|
generatedAt: now
|
|
38394
39006
|
}
|
|
38395
39007
|
}).run();
|
|
39008
|
+
settle({ status: OutcomeStatuses.succeeded, counts: generatedCounts("briefs", result.costMillicents) });
|
|
38396
39009
|
const row = app.db.select().from(recommendationBriefs).where(and45(
|
|
38397
39010
|
eq52(recommendationBriefs.projectId, project.id),
|
|
38398
39011
|
eq52(recommendationBriefs.targetRef, targetRef),
|
|
@@ -38400,7 +39013,7 @@ async function contentRoutes(app, opts = {}) {
|
|
|
38400
39013
|
)).get();
|
|
38401
39014
|
if (!row) throw notFound("recommendationBrief", targetRef);
|
|
38402
39015
|
return reply.send(formatBriefRow(row));
|
|
38403
|
-
});
|
|
39016
|
+
}, contentFailureReason));
|
|
38404
39017
|
app.get("/projects/:name/content/domain-classifications", async (request) => {
|
|
38405
39018
|
const project = resolveProject(app.db, request.params.name);
|
|
38406
39019
|
const rows = app.db.select().from(domainClassifications).where(eq52(domainClassifications.projectId, project.id)).orderBy(desc26(domainClassifications.hits)).all();
|
|
@@ -38449,7 +39062,7 @@ function winnabilityClassRank(winnabilityClass) {
|
|
|
38449
39062
|
}
|
|
38450
39063
|
|
|
38451
39064
|
// ../api-routes/src/openapi-schemas.ts
|
|
38452
|
-
import { z } from "zod";
|
|
39065
|
+
import { z as z3 } from "zod";
|
|
38453
39066
|
var SCHEMA_TABLE = {
|
|
38454
39067
|
SentimentSummary: sentimentSummarySchema,
|
|
38455
39068
|
SentimentSettings: sentimentSettingsSchema,
|
|
@@ -38725,7 +39338,7 @@ var SCHEMA_TABLE = {
|
|
|
38725
39338
|
SnapshotListResponse: snapshotListResponseSchema,
|
|
38726
39339
|
SnapshotReportDto: snapshotReportSchema,
|
|
38727
39340
|
// The API accepts legacy phrases, but the public contract advertises queries.
|
|
38728
|
-
SnapshotRequest:
|
|
39341
|
+
SnapshotRequest: z3.object(snapshotRequestInputSchema.shape).omit({ phrases: true }),
|
|
38729
39342
|
SourceBreakdownDto: sourceBreakdownDtoSchema,
|
|
38730
39343
|
TelemetryEventAcceptedDto: telemetryEventAcceptedDtoSchema,
|
|
38731
39344
|
FeedbackSubmission: feedbackSubmissionSchema,
|
|
@@ -38764,11 +39377,11 @@ var SCHEMA_TABLE = {
|
|
|
38764
39377
|
// the API never returns this from a typed handler — it's emitted by the
|
|
38765
39378
|
// global error handler. Codegen consumers reference it through the
|
|
38766
39379
|
// ErrorEnvelope ref so error shape is part of the public contract.
|
|
38767
|
-
ErrorEnvelope:
|
|
38768
|
-
error:
|
|
38769
|
-
code:
|
|
38770
|
-
message:
|
|
38771
|
-
details:
|
|
39380
|
+
ErrorEnvelope: z3.object({
|
|
39381
|
+
error: z3.object({
|
|
39382
|
+
code: z3.string(),
|
|
39383
|
+
message: z3.string(),
|
|
39384
|
+
details: z3.unknown().optional()
|
|
38772
39385
|
})
|
|
38773
39386
|
})
|
|
38774
39387
|
};
|
|
@@ -38793,7 +39406,7 @@ function buildComponentSchemas() {
|
|
|
38793
39406
|
const out = {};
|
|
38794
39407
|
for (const [name, schema] of Object.entries(SCHEMA_TABLE)) {
|
|
38795
39408
|
const io = INPUT_FORM_SCHEMAS.has(name) ? "input" : "output";
|
|
38796
|
-
const json =
|
|
39409
|
+
const json = z3.toJSONSchema(schema, { target: "openapi-3.0", io });
|
|
38797
39410
|
addNullToNullableEnums(json);
|
|
38798
39411
|
out[name] = json;
|
|
38799
39412
|
}
|
|
@@ -46812,7 +47425,7 @@ async function telemetryRoutes(app, opts) {
|
|
|
46812
47425
|
if (typeof enabled !== "boolean") {
|
|
46813
47426
|
throw validationError("enabled (boolean) is required");
|
|
46814
47427
|
}
|
|
46815
|
-
opts.setTelemetryEnabled(enabled);
|
|
47428
|
+
opts.setTelemetryEnabled(enabled, currentOutcomeAttribution());
|
|
46816
47429
|
const status = opts.getTelemetryStatus?.();
|
|
46817
47430
|
const effective = normalizeTelemetryStatus(status ?? { enabled });
|
|
46818
47431
|
if (app.hasDecorator("db")) {
|
|
@@ -47132,311 +47745,32 @@ function formatSchedule(row) {
|
|
|
47132
47745
|
// ../api-routes/src/notifications.ts
|
|
47133
47746
|
import crypto30 from "crypto";
|
|
47134
47747
|
import { eq as eq56 } from "drizzle-orm";
|
|
47135
|
-
|
|
47136
|
-
|
|
47137
|
-
import { z as z2 } from "zod";
|
|
47138
|
-
var alertSeveritySchema = z2.enum(["critical", "warning", "success", "info"]);
|
|
47139
|
-
var AlertSeverities = alertSeveritySchema.enum;
|
|
47140
|
-
var AlertFieldLabels = {
|
|
47141
|
-
status: "Status",
|
|
47142
|
-
check: "Check",
|
|
47143
|
-
previously: "Was",
|
|
47144
|
-
remediation: "What to do",
|
|
47145
|
-
alsoFailing: "Also failing",
|
|
47146
|
-
event: "Event",
|
|
47147
|
-
run: "Run",
|
|
47148
|
-
changes: "Changes",
|
|
47149
|
-
insights: "Insights",
|
|
47150
|
-
location: "Location",
|
|
47151
|
-
rating: "Rating",
|
|
47152
|
-
reviewer: "Reviewer",
|
|
47153
|
-
replied: "Replied",
|
|
47154
|
-
source: "Source"
|
|
47155
|
-
};
|
|
47156
|
-
var HEALTH_EVENTS = /* @__PURE__ */ new Set(["health.degraded", "health.recovered"]);
|
|
47157
|
-
function isHealth(payload) {
|
|
47158
|
-
return HEALTH_EVENTS.has(payload.event);
|
|
47159
|
-
}
|
|
47160
|
-
var MAX_LIST_ITEMS = 10;
|
|
47161
|
-
function bulletList(lines) {
|
|
47162
|
-
const shown = lines.slice(0, MAX_LIST_ITEMS);
|
|
47163
|
-
const omitted = lines.length - shown.length;
|
|
47164
|
-
const body = shown.map((line) => `\u2022 ${line}`).join("\n");
|
|
47165
|
-
return omitted > 0 ? `${body}
|
|
47166
|
-
\u2022 \u2026and ${omitted} more` : body;
|
|
47167
|
-
}
|
|
47168
|
-
function healthView(payload) {
|
|
47169
|
-
const { health, project } = payload;
|
|
47170
|
-
const recovered = payload.event === "health.recovered";
|
|
47171
|
-
const severity = recovered ? AlertSeverities.success : health.status === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
|
|
47172
|
-
const fields = [
|
|
47173
|
-
{ label: AlertFieldLabels.status, value: recovered ? "recovered" : health.status, compact: true },
|
|
47174
|
-
{ label: AlertFieldLabels.check, value: health.code, compact: true }
|
|
47175
|
-
];
|
|
47176
|
-
if (health.previousStatus) {
|
|
47177
|
-
fields.push({ label: AlertFieldLabels.previously, value: health.previousStatus, compact: true });
|
|
47178
|
-
}
|
|
47179
|
-
if (health.remediation) {
|
|
47180
|
-
fields.push({ label: AlertFieldLabels.remediation, value: health.remediation });
|
|
47181
|
-
}
|
|
47182
|
-
const others = health.failing.filter((check2) => check2.code !== health.code);
|
|
47183
|
-
if (others.length > 0) {
|
|
47184
|
-
fields.push({
|
|
47185
|
-
label: `${AlertFieldLabels.alsoFailing} (${others.length})`,
|
|
47186
|
-
value: bulletList(others.map((check2) => `${check2.status} \u2014 ${check2.code}`))
|
|
47187
|
-
});
|
|
47188
|
-
}
|
|
47189
|
-
const website = health.code.startsWith("site.reachability.");
|
|
47190
|
-
return {
|
|
47191
|
-
severity,
|
|
47192
|
-
title: website ? recovered ? `${project.name} website is back up` : `${project.name} website is down` : recovered ? `${project.name} recovered` : `${project.name} \u2014 measurement degraded`,
|
|
47193
|
-
body: health.summary,
|
|
47194
|
-
fields,
|
|
47195
|
-
url: payload.dashboardUrl,
|
|
47196
|
-
footer: project.canonicalDomain,
|
|
47197
|
-
timestamp: health.checkedAt
|
|
47198
|
-
};
|
|
47199
|
-
}
|
|
47200
|
-
function runView(payload) {
|
|
47201
|
-
const failed = payload.event === "run.failed";
|
|
47202
|
-
const transitions = "transitions" in payload ? payload.transitions : [];
|
|
47203
|
-
const insights2 = "insights" in payload ? payload.insights : [];
|
|
47204
|
-
const fields = [
|
|
47205
|
-
{ label: AlertFieldLabels.event, value: payload.event, compact: true },
|
|
47206
|
-
{ label: AlertFieldLabels.run, value: payload.run.status, compact: true }
|
|
47207
|
-
];
|
|
47208
|
-
if (transitions.length > 0) {
|
|
47209
|
-
fields.push({
|
|
47210
|
-
label: `${AlertFieldLabels.changes} (${transitions.length})`,
|
|
47211
|
-
value: bulletList(transitions.map((t) => `${t.provider}: ${t.from} \u2192 ${t.to} \u2014 ${t.query}`))
|
|
47212
|
-
});
|
|
47213
|
-
}
|
|
47214
|
-
if (insights2.length > 0) {
|
|
47215
|
-
fields.push({
|
|
47216
|
-
label: `${AlertFieldLabels.insights} (${insights2.length})`,
|
|
47217
|
-
value: bulletList(insights2.map((i) => `[${i.severity}] ${i.title}`))
|
|
47218
|
-
});
|
|
47219
|
-
}
|
|
47220
|
-
return {
|
|
47221
|
-
severity: failed ? AlertSeverities.critical : AlertSeverities.info,
|
|
47222
|
-
title: payload.project.name,
|
|
47223
|
-
fields,
|
|
47224
|
-
url: payload.dashboardUrl,
|
|
47225
|
-
footer: payload.project.canonicalDomain,
|
|
47226
|
-
...payload.run.finishedAt ? { timestamp: payload.run.finishedAt } : {}
|
|
47227
|
-
};
|
|
47228
|
-
}
|
|
47229
|
-
function stars(rating) {
|
|
47230
|
-
const filled = Math.max(0, Math.min(5, Math.round(rating)));
|
|
47231
|
-
return `${"\u2605".repeat(filled)}${"\u2606".repeat(5 - filled)}`;
|
|
47232
|
-
}
|
|
47233
|
-
function snippet(text2, max2 = 160) {
|
|
47234
|
-
const flat = text2.replace(/\s+/g, " ").trim();
|
|
47235
|
-
return flat.length > max2 ? `${flat.slice(0, max2 - 1)}\u2026` : flat;
|
|
47236
|
-
}
|
|
47237
|
-
var SOURCE_LABELS = {
|
|
47238
|
-
gbp: "Business Profile",
|
|
47239
|
-
places: "Public listing (Places, partial)"
|
|
47240
|
-
};
|
|
47241
|
-
function reviewView(payload) {
|
|
47242
|
-
const { project, reviews } = payload;
|
|
47243
|
-
const newest = reviews.reduce((latest, r) => !latest || r.updateTime > latest ? r.updateTime : latest, void 0);
|
|
47244
|
-
const origins = [...new Set(reviews.map((r) => r.origin))];
|
|
47245
|
-
const sourceField = {
|
|
47246
|
-
label: AlertFieldLabels.source,
|
|
47247
|
-
value: origins.map((origin) => SOURCE_LABELS[origin]).join(", "),
|
|
47248
|
-
compact: true
|
|
47249
|
-
};
|
|
47250
|
-
if (reviews.length === 1) {
|
|
47251
|
-
const review = reviews[0];
|
|
47252
|
-
const fields = [
|
|
47253
|
-
{ label: AlertFieldLabels.location, value: review.location.displayName, compact: true },
|
|
47254
|
-
{ label: AlertFieldLabels.rating, value: `${stars(review.starRating)} (${review.starRating}/5)`, compact: true }
|
|
47255
|
-
];
|
|
47256
|
-
if (review.reviewerName) fields.push({ label: AlertFieldLabels.reviewer, value: review.reviewerName, compact: true });
|
|
47257
|
-
if (review.replied !== null) fields.push({ label: AlertFieldLabels.replied, value: review.replied ? "yes" : "no", compact: true });
|
|
47258
|
-
fields.push(sourceField);
|
|
47259
|
-
return {
|
|
47260
|
-
severity: AlertSeverities.warning,
|
|
47261
|
-
title: `${project.name}: new ${review.starRating}-star review`,
|
|
47262
|
-
body: review.comment ?? "Rating only, no text.",
|
|
47263
|
-
fields,
|
|
47264
|
-
url: review.reviewUri ?? review.location.mapsUri ?? payload.dashboardUrl,
|
|
47265
|
-
footer: project.canonicalDomain,
|
|
47266
|
-
timestamp: review.updateTime
|
|
47267
|
-
};
|
|
47268
|
-
}
|
|
47269
|
-
return {
|
|
47270
|
-
severity: AlertSeverities.warning,
|
|
47271
|
-
title: `${project.name}: ${reviews.length} new negative reviews`,
|
|
47272
|
-
body: bulletList(reviews.map((r) => {
|
|
47273
|
-
const text2 = r.comment ? ` "${snippet(r.comment)}"` : "";
|
|
47274
|
-
const by = r.reviewerName ? ` (${r.reviewerName})` : "";
|
|
47275
|
-
return `${stars(r.starRating)} ${r.location.displayName}:${text2}${by}`;
|
|
47276
|
-
})),
|
|
47277
|
-
fields: [sourceField],
|
|
47278
|
-
url: payload.dashboardUrl,
|
|
47279
|
-
footer: project.canonicalDomain,
|
|
47280
|
-
...newest ? { timestamp: newest } : {}
|
|
47281
|
-
};
|
|
47282
|
-
}
|
|
47283
|
-
function ratingView(payload) {
|
|
47284
|
-
const { project, ratings } = payload;
|
|
47285
|
-
const line = (r) => {
|
|
47286
|
-
const counts2 = r.previousReviewCount !== null && r.reviewCount !== null ? ` (${r.previousReviewCount} \u2192 ${r.reviewCount} reviews)` : "";
|
|
47287
|
-
return `${r.location.displayName}: ${r.previousRating} \u2192 ${r.rating}${counts2}`;
|
|
47288
|
-
};
|
|
47289
|
-
const single = ratings.length === 1 ? ratings[0] : null;
|
|
47290
|
-
return {
|
|
47291
|
-
severity: AlertSeverities.warning,
|
|
47292
|
-
title: single ? `${project.name}: Google rating fell to ${single.rating}` : `${project.name}: Google rating fell at ${ratings.length} locations`,
|
|
47293
|
-
body: single ? line(single) : bulletList(ratings.map(line)),
|
|
47294
|
-
fields: [
|
|
47295
|
-
{ label: AlertFieldLabels.source, value: SOURCE_LABELS.places, compact: true },
|
|
47296
|
-
{
|
|
47297
|
-
label: AlertFieldLabels.remediation,
|
|
47298
|
-
value: "Read the newest reviews on Google Maps. Without Business Profile review access, Canonry sees at most five of them."
|
|
47299
|
-
}
|
|
47300
|
-
],
|
|
47301
|
-
url: single?.location.mapsUri ?? payload.dashboardUrl,
|
|
47302
|
-
footer: project.canonicalDomain,
|
|
47303
|
-
timestamp: ratings.reduce((latest, r) => r.observedAt > latest ? r.observedAt : latest, ratings[0]?.observedAt ?? "")
|
|
47304
|
-
};
|
|
47305
|
-
}
|
|
47306
|
-
function toAlertView(payload) {
|
|
47307
|
-
if (isHealth(payload)) return healthView(payload);
|
|
47308
|
-
if (payload.event === "review.negative") return reviewView(payload);
|
|
47309
|
-
if (payload.event === "review.rating-dropped") return ratingView(payload);
|
|
47310
|
-
return runView(payload);
|
|
47311
|
-
}
|
|
47312
|
-
|
|
47313
|
-
// ../api-routes/src/notifications/destinations.ts
|
|
47314
|
-
import { z as z3 } from "zod";
|
|
47315
|
-
var webhookDestinationSchema = z3.enum(["first-party", "discord", "slack"]);
|
|
47316
|
-
var WebhookDestinations = webhookDestinationSchema.enum;
|
|
47317
|
-
function hostIs(url, ...domains) {
|
|
47318
|
-
const host = url.hostname.toLowerCase();
|
|
47319
|
-
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
47320
|
-
}
|
|
47321
|
-
var DISCORD_MAX_DESCRIPTION = 3800;
|
|
47322
|
-
var DISCORD_MAX_FIELD = 1e3;
|
|
47323
|
-
var DISCORD_COLORS = {
|
|
47324
|
-
[AlertSeverities.critical]: 14235195,
|
|
47325
|
-
[AlertSeverities.warning]: 14256926,
|
|
47326
|
-
[AlertSeverities.success]: 3055183,
|
|
47327
|
-
[AlertSeverities.info]: 4873068
|
|
47328
|
-
};
|
|
47329
|
-
var SEVERITY_ICONS = {
|
|
47330
|
-
[AlertSeverities.critical]: "\u{1F534}",
|
|
47331
|
-
[AlertSeverities.warning]: "\u{1F7E0}",
|
|
47332
|
-
[AlertSeverities.success]: "\u2705",
|
|
47333
|
-
[AlertSeverities.info]: "\u{1F4CA}"
|
|
47334
|
-
};
|
|
47335
|
-
function clamp(value, max2) {
|
|
47336
|
-
return value.length <= max2 ? value : `${value.slice(0, max2 - 1)}\u2026`;
|
|
47337
|
-
}
|
|
47338
|
-
function absoluteLink(url) {
|
|
47339
|
-
if (!url) return void 0;
|
|
47340
|
-
try {
|
|
47341
|
-
const parsed = new URL(url);
|
|
47342
|
-
return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : void 0;
|
|
47343
|
-
} catch {
|
|
47344
|
-
return void 0;
|
|
47345
|
-
}
|
|
47346
|
-
}
|
|
47347
|
-
function renderDiscord(view) {
|
|
47748
|
+
var AGENT_WEBHOOK_SOURCE = "agent";
|
|
47749
|
+
function webhookConnection(action, url, source) {
|
|
47348
47750
|
return {
|
|
47349
|
-
|
|
47350
|
-
|
|
47351
|
-
|
|
47352
|
-
...view.body ? { description: clamp(view.body, DISCORD_MAX_DESCRIPTION) } : {},
|
|
47353
|
-
color: DISCORD_COLORS[view.severity],
|
|
47354
|
-
fields: view.fields.map((field) => ({
|
|
47355
|
-
name: field.label,
|
|
47356
|
-
value: clamp(field.value, DISCORD_MAX_FIELD),
|
|
47357
|
-
...field.compact ? { inline: true } : {}
|
|
47358
|
-
})),
|
|
47359
|
-
...absoluteLink(view.url) ? { url: absoluteLink(view.url) } : {},
|
|
47360
|
-
...view.timestamp ? { timestamp: view.timestamp } : {},
|
|
47361
|
-
...view.footer ? { footer: { text: view.footer } } : {}
|
|
47362
|
-
}]
|
|
47363
|
-
};
|
|
47364
|
-
}
|
|
47365
|
-
var SLACK_MAX_TEXT = 2900;
|
|
47366
|
-
var SLACK_MAX_FIELD = 1e3;
|
|
47367
|
-
var SLACK_COLORS = {
|
|
47368
|
-
[AlertSeverities.critical]: "#d9363b",
|
|
47369
|
-
[AlertSeverities.warning]: "#d98b1e",
|
|
47370
|
-
[AlertSeverities.success]: "#2e9e4f",
|
|
47371
|
-
[AlertSeverities.info]: "#4a5b6c"
|
|
47372
|
-
};
|
|
47373
|
-
function renderSlack(view) {
|
|
47374
|
-
const headline = `${SEVERITY_ICONS[view.severity]} ${view.title}`;
|
|
47375
|
-
return {
|
|
47376
|
-
// `text` is the notification preview and the accessible fallback, so it
|
|
47377
|
-
// must stand alone rather than repeat an empty string.
|
|
47378
|
-
text: headline,
|
|
47379
|
-
attachments: [{
|
|
47380
|
-
color: SLACK_COLORS[view.severity],
|
|
47381
|
-
title: headline,
|
|
47382
|
-
...absoluteLink(view.url) ? { title_link: absoluteLink(view.url) } : {},
|
|
47383
|
-
...view.body ? { text: clamp(view.body, SLACK_MAX_TEXT) } : {},
|
|
47384
|
-
fields: view.fields.map((field) => ({
|
|
47385
|
-
title: field.label,
|
|
47386
|
-
value: clamp(field.value, SLACK_MAX_FIELD),
|
|
47387
|
-
...field.compact ? { short: true } : {}
|
|
47388
|
-
})),
|
|
47389
|
-
...view.footer ? { footer: view.footer } : {},
|
|
47390
|
-
...view.timestamp ? { ts: Math.floor(new Date(view.timestamp).getTime() / 1e3) } : {}
|
|
47391
|
-
}]
|
|
47751
|
+
integration: source === AGENT_WEBHOOK_SOURCE ? "agent_webhook" : "webhook",
|
|
47752
|
+
action,
|
|
47753
|
+
...typeof url === "string" && url ? { target: webhookOutcomeTarget(url) } : {}
|
|
47392
47754
|
};
|
|
47393
47755
|
}
|
|
47394
|
-
var DISCORD_ADAPTER = {
|
|
47395
|
-
destination: WebhookDestinations.discord,
|
|
47396
|
-
matches: (url) => hostIs(url, "discord.com", "discordapp.com") && url.pathname.startsWith("/api/webhooks/"),
|
|
47397
|
-
render: renderDiscord,
|
|
47398
|
-
signed: false
|
|
47399
|
-
};
|
|
47400
|
-
var SLACK_ADAPTER = {
|
|
47401
|
-
destination: WebhookDestinations.slack,
|
|
47402
|
-
matches: (url) => hostIs(url, "slack.com") && url.pathname.startsWith("/services/"),
|
|
47403
|
-
render: renderSlack,
|
|
47404
|
-
signed: false
|
|
47405
|
-
};
|
|
47406
|
-
var FIRST_PARTY_ADAPTER = {
|
|
47407
|
-
destination: WebhookDestinations["first-party"],
|
|
47408
|
-
matches: () => true,
|
|
47409
|
-
signed: true
|
|
47410
|
-
};
|
|
47411
|
-
var DESTINATION_ADAPTERS = [
|
|
47412
|
-
DISCORD_ADAPTER,
|
|
47413
|
-
SLACK_ADAPTER,
|
|
47414
|
-
FIRST_PARTY_ADAPTER
|
|
47415
|
-
];
|
|
47416
|
-
function resolveDestination(rawUrl) {
|
|
47417
|
-
let url;
|
|
47418
|
-
try {
|
|
47419
|
-
url = new URL(rawUrl);
|
|
47420
|
-
} catch {
|
|
47421
|
-
return FIRST_PARTY_ADAPTER;
|
|
47422
|
-
}
|
|
47423
|
-
if (url.protocol !== "https:") return FIRST_PARTY_ADAPTER;
|
|
47424
|
-
return DESTINATION_ADAPTERS.find((adapter) => adapter.matches(url)) ?? FIRST_PARTY_ADAPTER;
|
|
47425
|
-
}
|
|
47426
|
-
|
|
47427
|
-
// ../api-routes/src/notifications.ts
|
|
47428
47756
|
var VALID_EVENTS = notificationEventSchema.options;
|
|
47429
47757
|
async function notificationRoutes(app, opts = {}) {
|
|
47430
47758
|
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
47431
47759
|
app.get("/notifications/events", async (_request, reply) => {
|
|
47432
47760
|
return reply.send(VALID_EVENTS);
|
|
47433
47761
|
});
|
|
47434
|
-
app.post("/projects/:name/notifications",
|
|
47762
|
+
app.post("/projects/:name/notifications", connectionRoute(app, (request) => {
|
|
47763
|
+
const body = request.body;
|
|
47764
|
+
return webhookConnection("connect", body?.url, body?.source);
|
|
47765
|
+
}, async (request, reply, attempt) => {
|
|
47435
47766
|
const project = resolveProject(app.db, request.params.name);
|
|
47436
47767
|
const { channel, url, events, source } = request.body ?? {};
|
|
47437
47768
|
if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
|
|
47438
47769
|
const urlCheck = await resolveWebhookTarget(url ?? "", { allowLoopback });
|
|
47439
|
-
if (!urlCheck.ok)
|
|
47770
|
+
if (!urlCheck.ok) {
|
|
47771
|
+
attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
|
|
47772
|
+
throw validationError(urlCheck.message);
|
|
47773
|
+
}
|
|
47440
47774
|
if (!events?.length) throw validationError('"events" must be a non-empty array');
|
|
47441
47775
|
const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
|
|
47442
47776
|
if (invalid.length) {
|
|
@@ -47467,13 +47801,17 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47467
47801
|
...formatNotification(app.db.select().from(notifications).where(eq56(notifications.id, id)).get()),
|
|
47468
47802
|
webhookSecret
|
|
47469
47803
|
});
|
|
47470
|
-
});
|
|
47804
|
+
}));
|
|
47471
47805
|
app.get("/projects/:name/notifications", async (request, reply) => {
|
|
47472
47806
|
const project = resolveProject(app.db, request.params.name);
|
|
47473
47807
|
const rows = app.db.select().from(notifications).where(eq56(notifications.projectId, project.id)).all();
|
|
47474
47808
|
return reply.send(rows.map(formatNotification));
|
|
47475
47809
|
});
|
|
47476
|
-
app.delete("/projects/:name/notifications/:id",
|
|
47810
|
+
app.delete("/projects/:name/notifications/:id", connectionRoute(app, (request) => {
|
|
47811
|
+
const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47812
|
+
const config = stored?.config;
|
|
47813
|
+
return config ? webhookConnection("disconnect", config.url, config.source) : void 0;
|
|
47814
|
+
}, async (request, reply) => {
|
|
47477
47815
|
const project = resolveProject(app.db, request.params.name);
|
|
47478
47816
|
const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47479
47817
|
if (!notification || notification.projectId !== project.id) {
|
|
@@ -47488,8 +47826,12 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47488
47826
|
entityId: notification.id
|
|
47489
47827
|
});
|
|
47490
47828
|
return reply.status(204).send();
|
|
47491
|
-
});
|
|
47492
|
-
app.post("/projects/:name/notifications/:id/test",
|
|
47829
|
+
}));
|
|
47830
|
+
app.post("/projects/:name/notifications/:id/test", connectionRoute(app, (request) => {
|
|
47831
|
+
const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47832
|
+
const config = stored?.config;
|
|
47833
|
+
return config ? webhookConnection("test", config.url, config.source) : void 0;
|
|
47834
|
+
}, async (request, reply, attempt) => {
|
|
47493
47835
|
const project = resolveProject(app.db, request.params.name);
|
|
47494
47836
|
const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47495
47837
|
if (!notification || notification.projectId !== project.id) {
|
|
@@ -47497,7 +47839,10 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47497
47839
|
}
|
|
47498
47840
|
const config = notification.config;
|
|
47499
47841
|
const urlCheck = await resolveWebhookTarget(config.url, { allowLoopback });
|
|
47500
|
-
if (!urlCheck.ok)
|
|
47842
|
+
if (!urlCheck.ok) {
|
|
47843
|
+
attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
|
|
47844
|
+
throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
|
|
47845
|
+
}
|
|
47501
47846
|
const payload = {
|
|
47502
47847
|
source: "canonry",
|
|
47503
47848
|
event: "run.completed",
|
|
@@ -47516,8 +47861,11 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47516
47861
|
const signingSecret = destination.signed ? notification.webhookSecret ?? null : null;
|
|
47517
47862
|
const targetLabel = redactNotificationUrl(config.url).urlDisplay;
|
|
47518
47863
|
request.log.info(`[Notification test] POST ${targetLabel} (${destination.destination})`);
|
|
47519
|
-
const
|
|
47864
|
+
const delivery = await deliverWebhook(urlCheck.target, body, signingSecret);
|
|
47865
|
+
const { status, error } = delivery;
|
|
47520
47866
|
request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
|
|
47867
|
+
const failure = webhookResponseReason(delivery);
|
|
47868
|
+
if (failure) attempt.failed(void 0, failure);
|
|
47521
47869
|
writeAuditLog(app.db, {
|
|
47522
47870
|
projectId: project.id,
|
|
47523
47871
|
actor: "api",
|
|
@@ -47528,7 +47876,7 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47528
47876
|
});
|
|
47529
47877
|
if (error) throw deliveryFailed(error);
|
|
47530
47878
|
return reply.send({ status, ok: status >= 200 && status < 300 });
|
|
47531
|
-
});
|
|
47879
|
+
}));
|
|
47532
47880
|
}
|
|
47533
47881
|
function formatNotification(row) {
|
|
47534
47882
|
const config = row.config;
|
|
@@ -49769,6 +50117,10 @@ function classifyReviewsError(err) {
|
|
|
49769
50117
|
}
|
|
49770
50118
|
|
|
49771
50119
|
// ../api-routes/src/google.ts
|
|
50120
|
+
var GOOGLE_INTEGRATIONS = { gsc: "gsc", ga4: "ga4", gbp: "gbp" };
|
|
50121
|
+
function googleIntegration(type) {
|
|
50122
|
+
return typeof type === "string" && Object.hasOwn(GOOGLE_INTEGRATIONS, type) ? GOOGLE_INTEGRATIONS[type] : void 0;
|
|
50123
|
+
}
|
|
49772
50124
|
function resolveReportedWindow(resolved, startDate, endDate) {
|
|
49773
50125
|
const start = startDate ?? resolved.startDate;
|
|
49774
50126
|
const end = endDate ?? resolved.endDate;
|
|
@@ -49902,6 +50254,19 @@ function googleAuthErrorStatus(err) {
|
|
|
49902
50254
|
const match = err.message.match(/failed \((\d{3})\)/);
|
|
49903
50255
|
return match ? Number(match[1]) : null;
|
|
49904
50256
|
}
|
|
50257
|
+
function gscOutcomeFailure(err) {
|
|
50258
|
+
const classified = classifyOutcomeError(err);
|
|
50259
|
+
if (err instanceof AppError && (err.code === "NOT_FOUND" || err.code === "VALIDATION_ERROR")) {
|
|
50260
|
+
return { ...classified, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
|
|
50261
|
+
}
|
|
50262
|
+
if (err instanceof GoogleAuthError) {
|
|
50263
|
+
const status = googleAuthErrorStatus(err);
|
|
50264
|
+
const reasonCode = status === 429 ? OutcomeReasonCodes.RATE_LIMITED : status !== null && status >= 500 ? OutcomeReasonCodes.HTTP_5XX : OutcomeReasonCodes.INVALID_CREDENTIALS;
|
|
50265
|
+
return { ...classified, reasonCode };
|
|
50266
|
+
}
|
|
50267
|
+
if (err instanceof GoogleApiError && err.status === 404) return { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND };
|
|
50268
|
+
return classified;
|
|
50269
|
+
}
|
|
49905
50270
|
function gscErrorToAppError(err, context) {
|
|
49906
50271
|
if (err instanceof AppError) return err;
|
|
49907
50272
|
if (err instanceof GoogleApiError) {
|
|
@@ -49985,7 +50350,10 @@ async function googleRoutes(app, opts) {
|
|
|
49985
50350
|
updatedAt: connection.updatedAt
|
|
49986
50351
|
}));
|
|
49987
50352
|
});
|
|
49988
|
-
app.post("/projects/:name/google/connect",
|
|
50353
|
+
app.post("/projects/:name/google/connect", connectionRoute(app, (request) => {
|
|
50354
|
+
const integration = googleIntegration(request.body?.type);
|
|
50355
|
+
return integration ? { integration, action: "connect" } : void 0;
|
|
50356
|
+
}, async (request, _reply, attempt) => {
|
|
49989
50357
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
49990
50358
|
if (!googleClientId || !googleClientSecret) {
|
|
49991
50359
|
throw validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
|
|
@@ -49995,6 +50363,7 @@ async function googleRoutes(app, opts) {
|
|
|
49995
50363
|
throw validationError('type must be "gsc", "ga4", or "gbp"');
|
|
49996
50364
|
}
|
|
49997
50365
|
const project = resolveProject(app.db, request.params.name);
|
|
50366
|
+
if (telemetryRead(() => opts.googleConnectionStore?.getConnection(project.canonicalDomain, type), void 0)) attempt.update({ action: "reauth" });
|
|
49998
50367
|
let redirectUri;
|
|
49999
50368
|
if (publicUrl) {
|
|
50000
50369
|
redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
|
|
@@ -50013,22 +50382,40 @@ async function googleRoutes(app, opts) {
|
|
|
50013
50382
|
domain: project.canonicalDomain,
|
|
50014
50383
|
type,
|
|
50015
50384
|
propertyId,
|
|
50016
|
-
redirectUri
|
|
50385
|
+
redirectUri,
|
|
50386
|
+
// The browser finishes the flow, so the callback reports the surface that started it.
|
|
50387
|
+
...oauthStartAttribution()
|
|
50017
50388
|
},
|
|
50018
50389
|
stateSecret
|
|
50019
50390
|
);
|
|
50020
50391
|
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
50392
|
+
attempt.started();
|
|
50021
50393
|
return { authUrl, redirectUri };
|
|
50022
|
-
});
|
|
50394
|
+
}));
|
|
50023
50395
|
async function handleOAuthCallback(request, reply) {
|
|
50396
|
+
const verifiedState = request.query.state ? verifySignedGoogleOAuthState(request.query.state, stateSecret) : null;
|
|
50397
|
+
const integration = googleIntegration(verifiedState?.type ?? unverifiedOAuthStateField(request.query.state, "type"));
|
|
50398
|
+
const replacesConnection = integration && typeof verifiedState?.domain === "string" && telemetryRead(() => opts.googleConnectionStore?.getConnection(verifiedState.domain, integration), void 0);
|
|
50399
|
+
const attempt = startConnectionAttempt(app, integration ? { integration, action: replacesConnection ? "reauth" : "connect", ...oauthStateAttribution(verifiedState) } : void 0);
|
|
50400
|
+
try {
|
|
50401
|
+
return await completeOAuthCallback(request, reply, attempt);
|
|
50402
|
+
} catch (err) {
|
|
50403
|
+
attempt.failed(err);
|
|
50404
|
+
throw err;
|
|
50405
|
+
}
|
|
50406
|
+
}
|
|
50407
|
+
async function completeOAuthCallback(request, reply, attempt) {
|
|
50024
50408
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50025
50409
|
if (!googleClientId || !googleClientSecret) {
|
|
50410
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
50026
50411
|
return reply.status(500).send("Google OAuth not configured");
|
|
50027
50412
|
}
|
|
50028
50413
|
const store = requireConnectionStore();
|
|
50029
50414
|
const escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
50030
50415
|
const { code, state, error } = request.query;
|
|
50031
50416
|
if (error) {
|
|
50417
|
+
if (error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
|
|
50418
|
+
else attempt.failed(void 0, "AUTH_DENIED");
|
|
50032
50419
|
const safeError = escapeHtml3(String(error));
|
|
50033
50420
|
const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
50034
50421
|
<h2 style="color:#ef4444">Redirect URI mismatch</h2>
|
|
@@ -50055,21 +50442,26 @@ async function googleRoutes(app, opts) {
|
|
|
50055
50442
|
return reply.type("text/html").send(errorHtml);
|
|
50056
50443
|
}
|
|
50057
50444
|
if (!code || !state) {
|
|
50445
|
+
attempt.failed(void 0, state ? "VALIDATION" : "OAUTH_STATE_INVALID");
|
|
50058
50446
|
return reply.status(400).send("Missing code or state parameter");
|
|
50059
50447
|
}
|
|
50060
50448
|
const stateData = verifySignedGoogleOAuthState(state, stateSecret);
|
|
50061
50449
|
if (!stateData) {
|
|
50450
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50062
50451
|
return reply.status(400).send("Invalid or tampered state parameter");
|
|
50063
50452
|
}
|
|
50064
50453
|
const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
|
|
50065
50454
|
if (!projectId) {
|
|
50455
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50066
50456
|
return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
|
|
50067
50457
|
}
|
|
50068
50458
|
const project = app.db.select().from(projects).where(eq57(projects.id, projectId)).get();
|
|
50069
50459
|
if (!project) {
|
|
50460
|
+
attempt.failed(void 0, "NOT_FOUND");
|
|
50070
50461
|
return reply.status(400).send("Project no longer exists. Restart the connect flow.");
|
|
50071
50462
|
}
|
|
50072
50463
|
if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
|
|
50464
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50073
50465
|
return reply.status(400).send(
|
|
50074
50466
|
`Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
|
|
50075
50467
|
);
|
|
@@ -50078,6 +50470,7 @@ async function googleRoutes(app, opts) {
|
|
|
50078
50470
|
try {
|
|
50079
50471
|
tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
50080
50472
|
} catch (err) {
|
|
50473
|
+
attempt.failed(err);
|
|
50081
50474
|
const msg = describeError(err);
|
|
50082
50475
|
return reply.type("text/html").send(
|
|
50083
50476
|
`<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
@@ -50095,6 +50488,7 @@ async function googleRoutes(app, opts) {
|
|
|
50095
50488
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
50096
50489
|
const existing = store.getConnection(domain, type);
|
|
50097
50490
|
if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
|
|
50491
|
+
attempt.failed(void 0, "ALREADY_CONNECTED");
|
|
50098
50492
|
return reply.status(403).send(
|
|
50099
50493
|
`This domain already has a Google ${String(type).toUpperCase()} connection owned by another project. Disconnect it from that project first (DELETE /api/v1/projects/<owner>/google/connections/${escapeHtml3(String(type))}) before re-connecting from "${escapeHtml3(projectName ?? "")}".`
|
|
50100
50494
|
);
|
|
@@ -50121,6 +50515,7 @@ async function googleRoutes(app, opts) {
|
|
|
50121
50515
|
entityId: type,
|
|
50122
50516
|
diff: { domain, type, propertyId }
|
|
50123
50517
|
});
|
|
50518
|
+
attempt.succeeded();
|
|
50124
50519
|
return reply.type("text/html").send(googleOAuthSuccessHtml(type));
|
|
50125
50520
|
}
|
|
50126
50521
|
app.get("/google/callback", async (request, reply) => {
|
|
@@ -50129,12 +50524,16 @@ async function googleRoutes(app, opts) {
|
|
|
50129
50524
|
app.get("/projects/:name/google/callback", async (request, reply) => {
|
|
50130
50525
|
return handleOAuthCallback(request, reply);
|
|
50131
50526
|
});
|
|
50132
|
-
app.delete("/projects/:name/google/connections/:type",
|
|
50527
|
+
app.delete("/projects/:name/google/connections/:type", connectionRoute(app, (request) => {
|
|
50528
|
+
const integration = googleIntegration(request.params.type);
|
|
50529
|
+
return integration ? { integration, action: "disconnect" } : void 0;
|
|
50530
|
+
}, async (request, reply, attempt) => {
|
|
50133
50531
|
const store = requireConnectionStore();
|
|
50134
50532
|
const project = resolveProject(app.db, request.params.name);
|
|
50135
50533
|
const type = request.params.type;
|
|
50136
50534
|
const existing = store.getConnection(project.canonicalDomain, type);
|
|
50137
50535
|
if (!existing) {
|
|
50536
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50138
50537
|
throw notFound("Google connection", type);
|
|
50139
50538
|
}
|
|
50140
50539
|
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
@@ -50144,6 +50543,7 @@ async function googleRoutes(app, opts) {
|
|
|
50144
50543
|
}
|
|
50145
50544
|
const deleted = store.deleteConnection(project.canonicalDomain, type);
|
|
50146
50545
|
if (!deleted) {
|
|
50546
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50147
50547
|
throw notFound("Google connection", type);
|
|
50148
50548
|
}
|
|
50149
50549
|
writeAuditLog(app.db, {
|
|
@@ -50154,7 +50554,7 @@ async function googleRoutes(app, opts) {
|
|
|
50154
50554
|
entityId: type
|
|
50155
50555
|
});
|
|
50156
50556
|
return reply.status(204).send();
|
|
50157
|
-
});
|
|
50557
|
+
}));
|
|
50158
50558
|
app.get("/projects/:name/google/properties", async (request) => {
|
|
50159
50559
|
assertNotProjectScoped(request, "listing Search Console properties");
|
|
50160
50560
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
@@ -50472,14 +50872,16 @@ async function googleRoutes(app, opts) {
|
|
|
50472
50872
|
if (!url) {
|
|
50473
50873
|
throw validationError("url is required");
|
|
50474
50874
|
}
|
|
50875
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "inspect");
|
|
50475
50876
|
let result;
|
|
50476
50877
|
try {
|
|
50477
50878
|
const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
50478
50879
|
if (!propertyId) {
|
|
50479
|
-
throw validationError("No GSC property configured for this connection");
|
|
50880
|
+
throw outcome2.refuse(OutcomeReasonCodes.PROPERTY_NOT_FOUND, validationError("No GSC property configured for this connection"));
|
|
50480
50881
|
}
|
|
50481
50882
|
result = await inspectUrl(accessToken, url, propertyId);
|
|
50482
50883
|
} catch (err) {
|
|
50884
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
|
|
50483
50885
|
throw gscErrorToAppError(err, "Failed to inspect URL in Search Console");
|
|
50484
50886
|
}
|
|
50485
50887
|
const ir = result.inspectionResult;
|
|
@@ -50506,6 +50908,7 @@ async function googleRoutes(app, opts) {
|
|
|
50506
50908
|
inspectedAt: now,
|
|
50507
50909
|
createdAt: now
|
|
50508
50910
|
}).run();
|
|
50911
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
|
|
50509
50912
|
return {
|
|
50510
50913
|
id,
|
|
50511
50914
|
url,
|
|
@@ -50730,31 +51133,43 @@ async function googleRoutes(app, opts) {
|
|
|
50730
51133
|
const sitemapUrls = [...new Set(parsed.data.sitemapUrls)];
|
|
50731
51134
|
const project = resolveProject(app.db, request.params.name);
|
|
50732
51135
|
const store = requireConnectionStore();
|
|
51136
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "sitemap_submit");
|
|
50733
51137
|
const conn = store.getConnection(project.canonicalDomain, "gsc");
|
|
50734
51138
|
if (!conn) {
|
|
50735
|
-
throw validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
|
|
51139
|
+
throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError('No GSC connection found for this domain. Run "canonry google connect" first.'));
|
|
50736
51140
|
}
|
|
50737
51141
|
if (!conn.propertyId) {
|
|
50738
|
-
throw
|
|
51142
|
+
throw outcome2.refuse(
|
|
51143
|
+
OutcomeReasonCodes.PROPERTY_NOT_FOUND,
|
|
51144
|
+
validationError('No GSC property configured for this connection. Set one with "canonry google set-property".')
|
|
51145
|
+
);
|
|
50739
51146
|
}
|
|
50740
51147
|
const invalidUrls = sitemapUrls.filter((url) => !isSitemapOwnedByProperty(url, conn.propertyId, project.canonicalDomain));
|
|
50741
51148
|
if (invalidUrls.length > 0) {
|
|
50742
|
-
throw
|
|
51149
|
+
throw outcome2.refuse(
|
|
51150
|
+
OutcomeReasonCodes.VALIDATION,
|
|
51151
|
+
validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`)
|
|
51152
|
+
);
|
|
50743
51153
|
}
|
|
50744
51154
|
if (!(conn.scopes ?? []).includes(GSC_SCOPE)) {
|
|
50745
|
-
throw
|
|
51155
|
+
throw outcome2.refuse(
|
|
51156
|
+
OutcomeReasonCodes.PERMISSION_MISSING,
|
|
51157
|
+
validationError('This GSC connection has the read-only webmasters scope and cannot submit sitemaps. Reconnect with "canonry google connect" to grant the full webmasters scope.')
|
|
51158
|
+
);
|
|
50746
51159
|
}
|
|
50747
51160
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50748
51161
|
if (!googleClientId || !googleClientSecret) {
|
|
50749
|
-
throw validationError("Google OAuth is not configured");
|
|
51162
|
+
throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError("Google OAuth is not configured"));
|
|
50750
51163
|
}
|
|
50751
51164
|
let accessToken;
|
|
50752
51165
|
try {
|
|
50753
51166
|
({ accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret));
|
|
50754
51167
|
} catch (err) {
|
|
51168
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
|
|
50755
51169
|
throw gscErrorToAppError(err, "Failed to authorize Search Console sitemap submission");
|
|
50756
51170
|
}
|
|
50757
51171
|
const results = [];
|
|
51172
|
+
let lastSubmitError;
|
|
50758
51173
|
for (const sitemapUrl of sitemapUrls) {
|
|
50759
51174
|
try {
|
|
50760
51175
|
await submitSitemap(accessToken, conn.propertyId, sitemapUrl);
|
|
@@ -50768,11 +51183,15 @@ async function googleRoutes(app, opts) {
|
|
|
50768
51183
|
});
|
|
50769
51184
|
results.push({ sitemapUrl, status: "accepted", submittedAt });
|
|
50770
51185
|
} catch (err) {
|
|
51186
|
+
lastSubmitError = err;
|
|
50771
51187
|
results.push({ sitemapUrl, status: "error", error: describeError(err) });
|
|
50772
51188
|
}
|
|
50773
51189
|
}
|
|
50774
51190
|
const accepted = results.filter((result) => result.status === "accepted").length;
|
|
50775
|
-
|
|
51191
|
+
const failed = results.length - accepted;
|
|
51192
|
+
const counts2 = { urls: accepted, failures: failed };
|
|
51193
|
+
outcome2.report(failed === 0 ? { status: OutcomeStatuses.succeeded, counts: counts2 } : { status: accepted > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, ...gscOutcomeFailure(lastSubmitError), counts: counts2 });
|
|
51194
|
+
return { summary: { total: results.length, accepted, failed }, results };
|
|
50776
51195
|
});
|
|
50777
51196
|
app.post("/projects/:name/google/gsc/discover-sitemaps", async (request) => {
|
|
50778
51197
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
@@ -50866,7 +51285,10 @@ async function googleRoutes(app, opts) {
|
|
|
50866
51285
|
}
|
|
50867
51286
|
return { sitemapUrl: sitemapUrl.trim() };
|
|
50868
51287
|
});
|
|
50869
|
-
app.put("/projects/:name/google/connections/:type/property",
|
|
51288
|
+
app.put("/projects/:name/google/connections/:type/property", connectionRoute(app, (request) => {
|
|
51289
|
+
const integration = googleIntegration(request.params.type);
|
|
51290
|
+
return integration ? { integration, action: "select" } : void 0;
|
|
51291
|
+
}, async (request, _reply, attempt) => {
|
|
50870
51292
|
const store = requireConnectionStore();
|
|
50871
51293
|
const project = resolveProject(app.db, request.params.name);
|
|
50872
51294
|
const { propertyId } = request.body ?? {};
|
|
@@ -50879,10 +51301,11 @@ async function googleRoutes(app, opts) {
|
|
|
50879
51301
|
{ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
50880
51302
|
);
|
|
50881
51303
|
if (!conn) {
|
|
51304
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50882
51305
|
throw notFound("Google connection", request.params.type);
|
|
50883
51306
|
}
|
|
50884
51307
|
return { propertyId };
|
|
50885
|
-
});
|
|
51308
|
+
}));
|
|
50886
51309
|
app.post("/projects/:name/google/indexing/request", async (request) => {
|
|
50887
51310
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50888
51311
|
if (!googleClientId || !googleClientSecret) {
|
|
@@ -51197,7 +51620,7 @@ async function googleRoutes(app, opts) {
|
|
|
51197
51620
|
}
|
|
51198
51621
|
return response;
|
|
51199
51622
|
});
|
|
51200
|
-
app.put("/projects/:name/gbp/locations/:locationName/selection", async (request) => {
|
|
51623
|
+
app.put("/projects/:name/gbp/locations/:locationName/selection", connectionRoute(app, () => ({ integration: "gbp", action: "select" }), async (request) => {
|
|
51201
51624
|
const project = resolveProject(app.db, request.params.name);
|
|
51202
51625
|
const locationName = decodeURIComponent(request.params.locationName);
|
|
51203
51626
|
const parsed = gbpLocationSelectionRequestSchema.safeParse(request.body ?? {});
|
|
@@ -51220,8 +51643,8 @@ async function googleRoutes(app, opts) {
|
|
|
51220
51643
|
});
|
|
51221
51644
|
const refreshed = app.db.select().from(gbpLocations).where(eq57(gbpLocations.id, existing.id)).get();
|
|
51222
51645
|
return rowToDto2(refreshed);
|
|
51223
|
-
});
|
|
51224
|
-
app.delete("/projects/:name/gbp/connection", async (request, reply) => {
|
|
51646
|
+
}));
|
|
51647
|
+
app.delete("/projects/:name/gbp/connection", connectionRoute(app, () => ({ integration: "gbp", action: "disconnect" }), async (request, reply, attempt) => {
|
|
51225
51648
|
const project = resolveProject(app.db, request.params.name);
|
|
51226
51649
|
const store = requireConnectionStore();
|
|
51227
51650
|
app.db.transaction((tx) => {
|
|
@@ -51233,9 +51656,9 @@ async function googleRoutes(app, opts) {
|
|
|
51233
51656
|
entityType: "gbp_connection"
|
|
51234
51657
|
});
|
|
51235
51658
|
});
|
|
51236
|
-
store.deleteConnection(project.canonicalDomain, "gbp");
|
|
51659
|
+
if (!store.deleteConnection(project.canonicalDomain, "gbp")) attempt.cancelled("NOT_CONNECTED");
|
|
51237
51660
|
return reply.status(204).send();
|
|
51238
|
-
});
|
|
51661
|
+
}));
|
|
51239
51662
|
app.post("/projects/:name/gbp/sync", async (request) => {
|
|
51240
51663
|
const project = resolveProject(app.db, request.params.name);
|
|
51241
51664
|
const store = requireConnectionStore();
|
|
@@ -51500,6 +51923,10 @@ var oauthStateSchema = z4.object({
|
|
|
51500
51923
|
}).strict();
|
|
51501
51924
|
var GOOGLE_MARKETING_OAUTH_PENDING_FLOW_MAX = 256;
|
|
51502
51925
|
var GOOGLE_MARKETING_OAUTH_COOKIE_PREFIX = "canonry_google_marketing_oauth_";
|
|
51926
|
+
var MARKETING_INTEGRATIONS = { "google-ads": "google_ads", gtm: "gtm" };
|
|
51927
|
+
function marketingConnection(provider, action = "connect") {
|
|
51928
|
+
return typeof provider === "string" && Object.hasOwn(MARKETING_INTEGRATIONS, provider) ? { integration: MARKETING_INTEGRATIONS[provider], action } : void 0;
|
|
51929
|
+
}
|
|
51503
51930
|
function oauthInitiator(request) {
|
|
51504
51931
|
const principal = request.principal;
|
|
51505
51932
|
return {
|
|
@@ -52226,7 +52653,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52226
52653
|
});
|
|
52227
52654
|
if (stateSecret) {
|
|
52228
52655
|
const registerOAuthConnect = (path8, provider) => {
|
|
52229
|
-
app.post(path8, async (request, reply) => {
|
|
52656
|
+
app.post(path8, connectionRoute(app, () => marketingConnection(provider), async (request, reply, attempt) => {
|
|
52230
52657
|
requireWrite(request);
|
|
52231
52658
|
if (request.principal && !request.principal.viaCookie) {
|
|
52232
52659
|
throw forbidden("Google Marketing OAuth must be started from a signed-in browser.");
|
|
@@ -52240,6 +52667,8 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52240
52667
|
}
|
|
52241
52668
|
const project = resolveProject(app.db, request.params.name);
|
|
52242
52669
|
const store = requireCredentialStore(opts);
|
|
52670
|
+
const connectionAction = telemetryRead(() => store.get(asProjectRef(project), provider), void 0) ? "reauth" : "connect";
|
|
52671
|
+
attempt.update({ action: connectionAction });
|
|
52243
52672
|
const adapter = opts.googleMarketingOAuth;
|
|
52244
52673
|
if (!adapter) throw notImplemented("Google Marketing OAuth is not configured for this deployment.");
|
|
52245
52674
|
const scopes = opts.googleMarketingOAuthScopes?.[provider];
|
|
@@ -52281,6 +52710,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52281
52710
|
expiresAtMs: nowMs + GOOGLE_OAUTH_STATE_MAX_AGE_MS,
|
|
52282
52711
|
initiator: oauthInitiator(request),
|
|
52283
52712
|
browserNonce,
|
|
52713
|
+
connectionAction,
|
|
52284
52714
|
...suppliedDeveloperToken ? { developerToken: suppliedDeveloperToken } : {}
|
|
52285
52715
|
}, nowMs);
|
|
52286
52716
|
appendSetCookie(reply, serializeOAuthBindingCookie({
|
|
@@ -52297,28 +52727,34 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52297
52727
|
entityId: provider,
|
|
52298
52728
|
diff: { provider }
|
|
52299
52729
|
}));
|
|
52730
|
+
attempt.started();
|
|
52300
52731
|
return {
|
|
52301
52732
|
provider,
|
|
52302
52733
|
authorizationUrl,
|
|
52303
52734
|
redirectUri,
|
|
52304
52735
|
expiresAt: null
|
|
52305
52736
|
};
|
|
52306
|
-
});
|
|
52737
|
+
}));
|
|
52307
52738
|
};
|
|
52308
52739
|
registerOAuthConnect("/projects/:name/google-ads/oauth/connect", GoogleMarketingProviders["google-ads"]);
|
|
52309
52740
|
registerOAuthConnect("/projects/:name/gtm/oauth/connect", GoogleMarketingProviders.gtm);
|
|
52310
|
-
app.get("/google-marketing/callback",
|
|
52741
|
+
app.get("/google-marketing/callback", connectionRoute(app, (request) => {
|
|
52742
|
+
const verified = oauthStateSchema.safeParse(verifySignedGoogleOAuthState(request.query.state ?? "", stateSecret));
|
|
52743
|
+
return marketingConnection(verified.success ? verified.data.provider : unverifiedOAuthStateField(request.query.state, "provider"));
|
|
52744
|
+
}, async (request, reply, attempt) => {
|
|
52311
52745
|
if (!request.query.state) {
|
|
52312
52746
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth state."));
|
|
52313
52747
|
}
|
|
52314
52748
|
const state = verifySignedGoogleOAuthState(request.query.state, stateSecret);
|
|
52315
52749
|
const parsedState = oauthStateSchema.safeParse(state);
|
|
52316
52750
|
if (!parsedState.success) {
|
|
52751
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52317
52752
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Invalid, expired, or tampered OAuth state."));
|
|
52318
52753
|
}
|
|
52319
52754
|
const browserNonce = parseCookieHeader(request.headers.cookie)[oauthBindingCookieName(parsedState.data.nonce)];
|
|
52320
52755
|
const pending = pendingOAuthFlows.consume(parsedState.data, browserNonce);
|
|
52321
52756
|
if (!pending) {
|
|
52757
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52322
52758
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "OAuth state is unknown, expired, already used, or was opened in a different browser."));
|
|
52323
52759
|
}
|
|
52324
52760
|
appendSetCookie(reply, serializeOAuthBindingCookie({
|
|
@@ -52327,39 +52763,48 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52327
52763
|
secure: new URL(parsedState.data.redirectUri).protocol === "https:",
|
|
52328
52764
|
opts
|
|
52329
52765
|
}));
|
|
52766
|
+
attempt.update({ action: pending.connectionAction ?? "connect" });
|
|
52330
52767
|
if (request.query.error) {
|
|
52331
52768
|
pendingOAuthFlows.finish(pending);
|
|
52769
|
+
if (request.query.error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
|
|
52770
|
+
else attempt.failed(void 0, "AUTH_DENIED");
|
|
52332
52771
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not approve this authorization request."));
|
|
52333
52772
|
}
|
|
52334
52773
|
if (!request.query.code) {
|
|
52335
52774
|
pendingOAuthFlows.finish(pending);
|
|
52775
|
+
attempt.failed(void 0, "VALIDATION");
|
|
52336
52776
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth code."));
|
|
52337
52777
|
}
|
|
52338
52778
|
const { projectId, projectName, provider, redirectUri } = parsedState.data;
|
|
52339
52779
|
const project = app.db.select().from(projects).where(eq58(projects.id, projectId)).get();
|
|
52340
52780
|
if (!project || project.name !== projectName) {
|
|
52341
52781
|
pendingOAuthFlows.finish(pending);
|
|
52782
|
+
attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
|
|
52342
52783
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
|
|
52343
52784
|
}
|
|
52344
52785
|
const store = opts.googleMarketingCredentialStore;
|
|
52345
52786
|
const adapter = opts.googleMarketingOAuth;
|
|
52346
52787
|
if (!store || !adapter) {
|
|
52347
52788
|
pendingOAuthFlows.finish(pending);
|
|
52789
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
52348
52790
|
return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
|
|
52349
52791
|
}
|
|
52350
52792
|
let tokens;
|
|
52351
52793
|
try {
|
|
52352
52794
|
tokens = await adapter.exchangeCode({ provider, code: request.query.code, redirectUri });
|
|
52353
|
-
} catch {
|
|
52795
|
+
} catch (err) {
|
|
52354
52796
|
pendingOAuthFlows.finish(pending);
|
|
52797
|
+
attempt.failed(err);
|
|
52355
52798
|
return reply.status(502).type("text/html").send(oauthHtml("Authorization failed", "Google rejected the OAuth code. Restart the connection flow."));
|
|
52356
52799
|
}
|
|
52357
52800
|
if (!pendingOAuthFlows.isCurrent(pending)) {
|
|
52358
52801
|
pendingOAuthFlows.finish(pending);
|
|
52802
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52359
52803
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52360
52804
|
}
|
|
52361
52805
|
if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.trim().length === 0) {
|
|
52362
52806
|
pendingOAuthFlows.finish(pending);
|
|
52807
|
+
attempt.failed(void 0, "PERMISSION_MISSING");
|
|
52363
52808
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not provide offline access. Restart the connection flow and approve access."));
|
|
52364
52809
|
}
|
|
52365
52810
|
let confirmationId;
|
|
@@ -52367,19 +52812,22 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52367
52812
|
confirmationId = pendingOAuthFlows.createConfirmation(pending, tokens);
|
|
52368
52813
|
} catch {
|
|
52369
52814
|
pendingOAuthFlows.finish(pending);
|
|
52815
|
+
attempt.failed(void 0, "RATE_LIMITED");
|
|
52370
52816
|
return reply.status(503).type("text/html").send(oauthHtml("Authorization failed", "Too many pending OAuth confirmations. Restart the connection flow."));
|
|
52371
52817
|
}
|
|
52372
52818
|
if (!confirmationId) {
|
|
52373
52819
|
pendingOAuthFlows.finish(pending);
|
|
52820
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52374
52821
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52375
52822
|
}
|
|
52823
|
+
attempt.deferred();
|
|
52376
52824
|
return reply.type("text/html").send(oauthConfirmationHtml({
|
|
52377
52825
|
provider,
|
|
52378
52826
|
projectName: project.name,
|
|
52379
52827
|
confirmationId,
|
|
52380
52828
|
opts
|
|
52381
52829
|
}));
|
|
52382
|
-
});
|
|
52830
|
+
}));
|
|
52383
52831
|
app.post("/google-marketing/callback/confirm/:confirmationId", async (request, reply) => {
|
|
52384
52832
|
if (request.principal && !request.principal.viaCookie) {
|
|
52385
52833
|
throw forbidden("Google Marketing OAuth confirmation requires the browser that started the connection.");
|
|
@@ -52395,18 +52843,22 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52395
52843
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth confirmation is unknown, expired, already used, or belongs to another browser session."));
|
|
52396
52844
|
}
|
|
52397
52845
|
const { flow, tokens } = confirmation;
|
|
52846
|
+
const attempt = startConnectionAttempt(app, marketingConnection(flow.provider, flow.connectionAction));
|
|
52398
52847
|
if (!pendingOAuthFlows.isCurrent(flow)) {
|
|
52399
52848
|
pendingOAuthFlows.finish(flow);
|
|
52849
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52400
52850
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52401
52851
|
}
|
|
52402
52852
|
const project = app.db.select().from(projects).where(eq58(projects.id, flow.projectId)).get();
|
|
52403
52853
|
if (!project || project.name !== flow.projectName) {
|
|
52404
52854
|
pendingOAuthFlows.finish(flow);
|
|
52855
|
+
attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
|
|
52405
52856
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
|
|
52406
52857
|
}
|
|
52407
52858
|
const store = opts.googleMarketingCredentialStore;
|
|
52408
52859
|
if (!store) {
|
|
52409
52860
|
pendingOAuthFlows.finish(flow);
|
|
52861
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
52410
52862
|
return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
|
|
52411
52863
|
}
|
|
52412
52864
|
const projectRef = asProjectRef(project);
|
|
@@ -52494,6 +52946,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52494
52946
|
});
|
|
52495
52947
|
});
|
|
52496
52948
|
} catch (error) {
|
|
52949
|
+
attempt.failed(error);
|
|
52497
52950
|
if (rollbackCredential) {
|
|
52498
52951
|
try {
|
|
52499
52952
|
rollbackCredential();
|
|
@@ -52505,6 +52958,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52505
52958
|
} finally {
|
|
52506
52959
|
pendingOAuthFlows.finish(flow);
|
|
52507
52960
|
}
|
|
52961
|
+
attempt.succeeded();
|
|
52508
52962
|
const label = flow.provider === GoogleMarketingProviders["google-ads"] ? "Google Ads" : "Google Tag Manager";
|
|
52509
52963
|
return reply.type("text/html").send(oauthHtml("Connected successfully", `${label} is now linked to this project.`));
|
|
52510
52964
|
});
|
|
@@ -52595,16 +53049,24 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52595
53049
|
enqueueAfterCommit(app, callback, run.id, projectId, kind);
|
|
52596
53050
|
return run.id;
|
|
52597
53051
|
};
|
|
52598
|
-
app.put("/projects/:name/google-ads/selection", async (request) => {
|
|
53052
|
+
app.put("/projects/:name/google-ads/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "select"), async (request, _reply, attempt) => {
|
|
52599
53053
|
requireWrite(request);
|
|
52600
53054
|
const parsedBody = parseBody3(googleAdsCustomerSelectionRequestSchema, request.body, "Google Ads selection");
|
|
52601
53055
|
const body = canonicalizeGoogleAdsCustomerSelection(parsedBody);
|
|
52602
53056
|
if (!body) throw validationError("Invalid Google Ads selection.");
|
|
52603
53057
|
const project = resolveProject(app.db, request.params.name);
|
|
52604
53058
|
const projectRef = asProjectRef(project);
|
|
52605
|
-
|
|
53059
|
+
try {
|
|
53060
|
+
requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
|
|
53061
|
+
} catch (err) {
|
|
53062
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
53063
|
+
throw err;
|
|
53064
|
+
}
|
|
52606
53065
|
const existing = app.db.select().from(googleAdsConnections).where(eq58(googleAdsConnections.projectId, project.id)).get();
|
|
52607
|
-
if (!existing)
|
|
53066
|
+
if (!existing) {
|
|
53067
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
53068
|
+
throw validationError("Connect Google Ads before selecting a customer.");
|
|
53069
|
+
}
|
|
52608
53070
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
52609
53071
|
const customerChanged = existing.selectedCustomerId !== body.customerId;
|
|
52610
53072
|
app.db.transaction((tx) => {
|
|
@@ -52637,17 +53099,25 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52637
53099
|
});
|
|
52638
53100
|
queueSelectionSync(request, project.id, "google-ads-sync", opts.onGoogleAdsSyncRequested);
|
|
52639
53101
|
return googleAdsStatus(app, opts, projectRef);
|
|
52640
|
-
});
|
|
52641
|
-
app.put("/projects/:name/gtm/selection", async (request) => {
|
|
53102
|
+
}));
|
|
53103
|
+
app.put("/projects/:name/gtm/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "select"), async (request, _reply, attempt) => {
|
|
52642
53104
|
requireWrite(request);
|
|
52643
53105
|
const parsedBody = parseBody3(gtmResourceSelectionRequestSchema, request.body, "GTM selection");
|
|
52644
53106
|
const body = canonicalizeGtmResourceSelection(parsedBody);
|
|
52645
53107
|
if (!body) throw validationError("Invalid GTM selection.");
|
|
52646
53108
|
const project = resolveProject(app.db, request.params.name);
|
|
52647
53109
|
const projectRef = asProjectRef(project);
|
|
52648
|
-
|
|
53110
|
+
try {
|
|
53111
|
+
requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
|
|
53112
|
+
} catch (err) {
|
|
53113
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
53114
|
+
throw err;
|
|
53115
|
+
}
|
|
52649
53116
|
const existing = app.db.select().from(gtmConnections).where(eq58(gtmConnections.projectId, project.id)).get();
|
|
52650
|
-
if (!existing)
|
|
53117
|
+
if (!existing) {
|
|
53118
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
53119
|
+
throw validationError("Connect Google Tag Manager before selecting resources.");
|
|
53120
|
+
}
|
|
52651
53121
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
52652
53122
|
const selectedWorkspaceId = body.workspaceId ?? null;
|
|
52653
53123
|
const containerChanged = existing.selectedAccountId !== body.accountId || existing.selectedContainerId !== body.containerId;
|
|
@@ -52681,7 +53151,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52681
53151
|
});
|
|
52682
53152
|
queueSelectionSync(request, project.id, "gtm-sync", opts.onGtmSyncRequested);
|
|
52683
53153
|
return gtmStatus(app, opts, projectRef);
|
|
52684
|
-
});
|
|
53154
|
+
}));
|
|
52685
53155
|
app.post("/projects/:name/google-ads/sync", async (request) => {
|
|
52686
53156
|
requireLiveRead(request);
|
|
52687
53157
|
requireWrite(request);
|
|
@@ -52823,7 +53293,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52823
53293
|
if (!snapshot) throw providerError("Stored GTM snapshot is invalid.");
|
|
52824
53294
|
return { snapshot };
|
|
52825
53295
|
});
|
|
52826
|
-
app.delete("/projects/:name/google-ads/connection", async (request) => {
|
|
53296
|
+
app.delete("/projects/:name/google-ads/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "disconnect"), async (request, _reply, attempt) => {
|
|
52827
53297
|
requireWrite(request);
|
|
52828
53298
|
const project = resolveProject(app.db, request.params.name);
|
|
52829
53299
|
const projectRef = asProjectRef(project);
|
|
@@ -52859,9 +53329,10 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52859
53329
|
}));
|
|
52860
53330
|
});
|
|
52861
53331
|
}
|
|
53332
|
+
if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
|
|
52862
53333
|
return { provider: GoogleMarketingProviders["google-ads"], disconnected: Boolean(row) || removedCredential };
|
|
52863
|
-
});
|
|
52864
|
-
app.delete("/projects/:name/gtm/connection", async (request) => {
|
|
53334
|
+
}));
|
|
53335
|
+
app.delete("/projects/:name/gtm/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "disconnect"), async (request, _reply, attempt) => {
|
|
52865
53336
|
requireWrite(request);
|
|
52866
53337
|
const project = resolveProject(app.db, request.params.name);
|
|
52867
53338
|
const projectRef = asProjectRef(project);
|
|
@@ -52895,8 +53366,9 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52895
53366
|
}));
|
|
52896
53367
|
});
|
|
52897
53368
|
}
|
|
53369
|
+
if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
|
|
52898
53370
|
return { provider: GoogleMarketingProviders.gtm, disconnected: Boolean(row) || removedCredential };
|
|
52899
|
-
});
|
|
53371
|
+
}));
|
|
52900
53372
|
app.get("/projects/:name/conversion-tracking/options", async (request) => {
|
|
52901
53373
|
const project = resolveProject(app.db, request.params.name);
|
|
52902
53374
|
const adsSnapshot = latestGoogleAdsSnapshot(app, project.id);
|
|
@@ -54912,6 +55384,31 @@ function enforceAdsActivationOperationalCap(manifest) {
|
|
|
54912
55384
|
);
|
|
54913
55385
|
}
|
|
54914
55386
|
}
|
|
55387
|
+
var ACTIVATION_FAILURE_REASONS = {
|
|
55388
|
+
[AdsActivationErrorCodes.invalidManifest]: OutcomeReasonCodes.VALIDATION,
|
|
55389
|
+
[AdsActivationErrorCodes.grantNotFound]: OutcomeReasonCodes.NOT_FOUND,
|
|
55390
|
+
[AdsActivationErrorCodes.grantProjectMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55391
|
+
[AdsActivationErrorCodes.grantAccountMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55392
|
+
[AdsActivationErrorCodes.grantHashMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55393
|
+
[AdsActivationErrorCodes.grantExecutorMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55394
|
+
[AdsActivationErrorCodes.grantExpired]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55395
|
+
[AdsActivationErrorCodes.grantRevoked]: OutcomeReasonCodes.CANCELLED_BY_USER,
|
|
55396
|
+
[AdsActivationErrorCodes.grantUsed]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55397
|
+
[AdsActivationErrorCodes.operationConflict]: OutcomeReasonCodes.VALIDATION,
|
|
55398
|
+
[AdsActivationErrorCodes.accountNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55399
|
+
[AdsActivationErrorCodes.entityMismatch]: OutcomeReasonCodes.VALIDATION,
|
|
55400
|
+
[AdsActivationErrorCodes.entityNotPaused]: OutcomeReasonCodes.VALIDATION,
|
|
55401
|
+
[AdsActivationErrorCodes.entityStale]: OutcomeReasonCodes.VALIDATION,
|
|
55402
|
+
[AdsActivationErrorCodes.adNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55403
|
+
[AdsActivationErrorCodes.manualRemediationRequired]: OutcomeReasonCodes.QUARANTINED,
|
|
55404
|
+
[AdsActivationErrorCodes.persistenceFailed]: OutcomeReasonCodes.INTERNAL
|
|
55405
|
+
};
|
|
55406
|
+
function activationFailure(error) {
|
|
55407
|
+
const known = error instanceof AdsActivationError ? ACTIVATION_FAILURE_REASONS[error.code] : void 0;
|
|
55408
|
+
const failure = known ? { ...classifyOutcomeError(error), reasonCode: known } : upstreamFailure(error);
|
|
55409
|
+
const status = failure.reasonCode === OutcomeReasonCodes.QUARANTINED ? OutcomeStatuses.partial : failure.reasonCode === OutcomeReasonCodes.CANCELLED_BY_USER ? OutcomeStatuses.cancelled : OutcomeStatuses.failed;
|
|
55410
|
+
return { status, ...failure };
|
|
55411
|
+
}
|
|
54915
55412
|
function requireAuthenticatedKey(request) {
|
|
54916
55413
|
if (!request.apiKey) {
|
|
54917
55414
|
throw forbidden("Ads activation approval and execution require an authenticated API key");
|
|
@@ -54987,21 +55484,39 @@ function terminalActivationResponse(app, opts, grant, operationKey) {
|
|
|
54987
55484
|
return activationResponse(opts, canonical.grant, canonical.operation);
|
|
54988
55485
|
}
|
|
54989
55486
|
async function executeActivationGrant(app, opts, request, project, executorApiKeyId, grant, operationKey) {
|
|
54990
|
-
const
|
|
54991
|
-
const
|
|
54992
|
-
|
|
54993
|
-
|
|
54994
|
-
|
|
54995
|
-
|
|
54996
|
-
|
|
54997
|
-
|
|
54998
|
-
|
|
54999
|
-
|
|
55000
|
-
|
|
55001
|
-
|
|
55002
|
-
|
|
55003
|
-
|
|
55004
|
-
|
|
55487
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation", request ? {} : { trigger: OutcomeTriggers.retry });
|
|
55488
|
+
const store = createActivationStore(app, request);
|
|
55489
|
+
let claimKind;
|
|
55490
|
+
try {
|
|
55491
|
+
const runtime = await opts.resolveRuntime(project);
|
|
55492
|
+
const result = await executeApprovedAdsActivation({
|
|
55493
|
+
store: {
|
|
55494
|
+
...store,
|
|
55495
|
+
claimGrantAndOperation: async (input) => {
|
|
55496
|
+
const claim = await store.claimGrantAndOperation(input);
|
|
55497
|
+
claimKind = claim.kind;
|
|
55498
|
+
return claim;
|
|
55499
|
+
}
|
|
55500
|
+
},
|
|
55501
|
+
provider: runtime.provider,
|
|
55502
|
+
leaseMs: opts.leaseMs ?? ACTIVATION_LEASE_MS
|
|
55503
|
+
}, {
|
|
55504
|
+
projectId: project.id,
|
|
55505
|
+
adAccountId: runtime.adAccountId,
|
|
55506
|
+
operationKey,
|
|
55507
|
+
grantId: grant.id,
|
|
55508
|
+
manifestHash: grant.manifestHash,
|
|
55509
|
+
executorApiKeyId,
|
|
55510
|
+
manifest: grant.manifest
|
|
55511
|
+
});
|
|
55512
|
+
if (claimKind === "claimed" || claimKind === "resumed") {
|
|
55513
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { campaigns: 1, operations: result.steps.length } });
|
|
55514
|
+
}
|
|
55515
|
+
return activationResponse(opts, result.grant, result.operation);
|
|
55516
|
+
} catch (error) {
|
|
55517
|
+
outcome2.report(activationFailure(error));
|
|
55518
|
+
throw error;
|
|
55519
|
+
}
|
|
55005
55520
|
}
|
|
55006
55521
|
function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
|
|
55007
55522
|
const leaseAvailable = or15(
|
|
@@ -55356,7 +55871,8 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55356
55871
|
const approver = requireAuthenticatedKey(request);
|
|
55357
55872
|
const project = resolveProject(app.db, request.params.name);
|
|
55358
55873
|
const body = parseBody4(adsActivationGrantCreateRequestSchema, request.body);
|
|
55359
|
-
|
|
55874
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation");
|
|
55875
|
+
outcome2.guard(OutcomeReasonCodes.GATE_REFUSED, () => enforceAdsActivationOperationalCap(body.manifest));
|
|
55360
55876
|
const now = /* @__PURE__ */ new Date();
|
|
55361
55877
|
const expiresAt = Date.parse(body.expiresAt);
|
|
55362
55878
|
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime() || expiresAt > now.getTime() + MAX_ACTIVATION_GRANT_LIFETIME_MS) {
|
|
@@ -55375,7 +55891,13 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55375
55891
|
if (!executor.scopes.includes("*") && !executor.scopes.includes(ADS_ACTIVATE_SCOPE)) {
|
|
55376
55892
|
throw forbidden(`The executor API key requires the "${ADS_ACTIVATE_SCOPE}" scope`);
|
|
55377
55893
|
}
|
|
55378
|
-
|
|
55894
|
+
let runtime;
|
|
55895
|
+
try {
|
|
55896
|
+
runtime = await opts.resolveRuntime(project);
|
|
55897
|
+
} catch (error) {
|
|
55898
|
+
outcome2.report(activationFailure(error));
|
|
55899
|
+
throw error;
|
|
55900
|
+
}
|
|
55379
55901
|
let preflight;
|
|
55380
55902
|
try {
|
|
55381
55903
|
preflight = body.versionPolicy === AdsActivationVersionPolicies.refreshSemanticallyUnchanged ? await refreshSemanticallyUnchangedAdsActivationManifest(runtime.provider, {
|
|
@@ -55387,6 +55909,7 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55387
55909
|
manifest: body.manifest
|
|
55388
55910
|
});
|
|
55389
55911
|
} catch (error) {
|
|
55912
|
+
outcome2.report(activationFailure(error));
|
|
55390
55913
|
if (error instanceof AdsActivationError) throw mapApprovalPreflightError(error);
|
|
55391
55914
|
throw error;
|
|
55392
55915
|
}
|
|
@@ -55495,6 +56018,9 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55495
56018
|
if (grant.revocationRequestedAt !== null && (grant.state === AdsActivationGrantStates.executing || grant.state === AdsActivationGrantStates.unknown)) {
|
|
55496
56019
|
startWatchdogSweep?.();
|
|
55497
56020
|
}
|
|
56021
|
+
if (existing.state === AdsActivationGrantStates.approved && grant.state === AdsActivationGrantStates.revoked) {
|
|
56022
|
+
startRouteOutcome(app, FeatureNames.openai_ads, "activation").report({ status: OutcomeStatuses.cancelled, reasonCode: OutcomeReasonCodes.CANCELLED_BY_USER });
|
|
56023
|
+
}
|
|
55498
56024
|
return adsActivationGrantResponseSchema.parse({ grant });
|
|
55499
56025
|
}
|
|
55500
56026
|
);
|
|
@@ -56268,15 +56794,21 @@ function storedEntityState(row) {
|
|
|
56268
56794
|
};
|
|
56269
56795
|
}
|
|
56270
56796
|
async function executeAdsOperation(app, request, input) {
|
|
56797
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "operation");
|
|
56271
56798
|
const hash2 = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
|
|
56272
56799
|
const existing = readOperationByKey(app, input.projectId, input.operationKey);
|
|
56273
56800
|
if (existing) {
|
|
56274
56801
|
if (existing.requestHash !== hash2) {
|
|
56275
|
-
throw alreadyExists("Ads operation key", input.operationKey);
|
|
56802
|
+
throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
|
|
56276
56803
|
}
|
|
56277
56804
|
return { operation: operationDto(existing), replayed: true };
|
|
56278
56805
|
}
|
|
56279
|
-
|
|
56806
|
+
try {
|
|
56807
|
+
await input.preflight?.();
|
|
56808
|
+
} catch (err) {
|
|
56809
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...upstreamFailure(err) });
|
|
56810
|
+
throw err;
|
|
56811
|
+
}
|
|
56280
56812
|
const id = crypto39.randomUUID();
|
|
56281
56813
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
56282
56814
|
const reconcileFields = input.reconciliation.fields ?? null;
|
|
@@ -56308,10 +56840,10 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56308
56840
|
eq60(adsOperations.operationKey, input.operationKey)
|
|
56309
56841
|
)).get();
|
|
56310
56842
|
if (!canonical) {
|
|
56311
|
-
throw providerError("Ads operation receipt could not be claimed");
|
|
56843
|
+
throw outcome2.refuse(OutcomeReasonCodes.INTERNAL, providerError("Ads operation receipt could not be claimed"));
|
|
56312
56844
|
}
|
|
56313
56845
|
if (canonical.requestHash !== hash2) {
|
|
56314
|
-
throw alreadyExists("Ads operation key", input.operationKey);
|
|
56846
|
+
throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
|
|
56315
56847
|
}
|
|
56316
56848
|
if (inserted.length === 0) {
|
|
56317
56849
|
return { operation: operationDto(canonical), replayed: true };
|
|
@@ -56360,6 +56892,7 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56360
56892
|
}));
|
|
56361
56893
|
}
|
|
56362
56894
|
});
|
|
56895
|
+
outcome2.report({ status: OutcomeStatuses.succeeded });
|
|
56363
56896
|
const row = app.db.select().from(adsOperations).where(eq60(adsOperations.id, id)).get();
|
|
56364
56897
|
return { operation: operationDto(row), replayed: false };
|
|
56365
56898
|
} catch (err) {
|
|
@@ -56390,6 +56923,10 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56390
56923
|
}
|
|
56391
56924
|
return update.changes > 0;
|
|
56392
56925
|
});
|
|
56926
|
+
outcome2.report({
|
|
56927
|
+
status: failure.state === AdsOperationStates.failed ? OutcomeStatuses.failed : OutcomeStatuses.partial,
|
|
56928
|
+
...upstreamFailure(err)
|
|
56929
|
+
});
|
|
56393
56930
|
if (!finalized) {
|
|
56394
56931
|
const canonical2 = app.db.select().from(adsOperations).where(and50(
|
|
56395
56932
|
eq60(adsOperations.id, id),
|
|
@@ -56605,8 +57142,17 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56605
57142
|
maxAttempts: Math.min(20, Math.max(1, input.maxAttempts ?? DEFAULT_RECONCILE_MAX_ATTEMPTS))
|
|
56606
57143
|
};
|
|
56607
57144
|
const context = input.audit ?? { actor: "system" };
|
|
57145
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", context.request ? {} : { trigger: OutcomeTriggers.scheduled });
|
|
57146
|
+
const reportInProgress = () => outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.OPERATION_IN_PROGRESS });
|
|
57147
|
+
const finished = (row, failure) => {
|
|
57148
|
+
if (row.state === AdsOperationStates.succeeded) outcome2.report({ status: OutcomeStatuses.succeeded });
|
|
57149
|
+
else if (row.errorCode === ADS_RECONCILIATION_QUARANTINED) outcome2.report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57150
|
+
else outcome2.report({ status: OutcomeStatuses.failed, ...failure });
|
|
57151
|
+
return reconcileResponse(row);
|
|
57152
|
+
};
|
|
56608
57153
|
const pendingWaitMs = existing.state === AdsOperationStates.pending ? pendingIdleRemainingMs(existing, now, policy.pendingMinIdleMs) : 0;
|
|
56609
57154
|
if (pendingWaitMs > 0) {
|
|
57155
|
+
reportInProgress();
|
|
56610
57156
|
throw operationInProgress(
|
|
56611
57157
|
"The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
|
|
56612
57158
|
{
|
|
@@ -56617,7 +57163,14 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56617
57163
|
);
|
|
56618
57164
|
}
|
|
56619
57165
|
if (existing.reconcileAttempts >= policy.maxAttempts) {
|
|
56620
|
-
|
|
57166
|
+
const row = quarantineExhaustedReconciliation(app, existing, now, policy, context);
|
|
57167
|
+
if (row.errorCode !== ADS_RECONCILIATION_QUARANTINED) {
|
|
57168
|
+
reportInProgress();
|
|
57169
|
+
} else {
|
|
57170
|
+
const already = existing.errorCode === ADS_RECONCILIATION_QUARANTINED;
|
|
57171
|
+
outcome2.report({ status: already ? OutcomeStatuses.skipped : OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57172
|
+
}
|
|
57173
|
+
return reconcileResponse(row);
|
|
56621
57174
|
}
|
|
56622
57175
|
const leaseOwner = `ads-reconcile:${crypto39.randomUUID()}`;
|
|
56623
57176
|
const claimed = claimOperationForReconciliation(
|
|
@@ -56635,6 +57188,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56635
57188
|
if (canonical.state === AdsOperationStates.pending) {
|
|
56636
57189
|
const retryAfterMs = pendingIdleRemainingMs(canonical, now, policy.pendingMinIdleMs);
|
|
56637
57190
|
if (retryAfterMs > 0) {
|
|
57191
|
+
reportInProgress();
|
|
56638
57192
|
throw operationInProgress(
|
|
56639
57193
|
"The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
|
|
56640
57194
|
{
|
|
@@ -56645,6 +57199,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56645
57199
|
);
|
|
56646
57200
|
}
|
|
56647
57201
|
}
|
|
57202
|
+
reportInProgress();
|
|
56648
57203
|
return {
|
|
56649
57204
|
operation: operationDto(canonical),
|
|
56650
57205
|
resolved: canonical.state === AdsOperationStates.succeeded
|
|
@@ -56660,7 +57215,9 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56660
57215
|
claimed.adAccountId ? "The current credential is connected to a different OpenAI ad account" : "This legacy receipt is not bound to a verified OpenAI ad account",
|
|
56661
57216
|
policy.maxAttempts
|
|
56662
57217
|
);
|
|
56663
|
-
return
|
|
57218
|
+
return finished(row, {
|
|
57219
|
+
reasonCode: claimed.adAccountId ? OutcomeReasonCodes.ACCOUNT_NOT_FOUND : OutcomeReasonCodes.UNSUPPORTED
|
|
57220
|
+
});
|
|
56664
57221
|
}
|
|
56665
57222
|
const strategy = claimed.reconcileStrategy;
|
|
56666
57223
|
const desired = claimed.reconcileFields;
|
|
@@ -56674,7 +57231,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56674
57231
|
"This operation cannot be reconciled automatically",
|
|
56675
57232
|
policy.maxAttempts
|
|
56676
57233
|
);
|
|
56677
|
-
return
|
|
57234
|
+
return finished(row, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
56678
57235
|
}
|
|
56679
57236
|
try {
|
|
56680
57237
|
let match;
|
|
@@ -56689,7 +57246,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56689
57246
|
"The create outcome has no checkpointed provider id and cannot be resolved automatically",
|
|
56690
57247
|
policy.maxAttempts
|
|
56691
57248
|
);
|
|
56692
|
-
return
|
|
57249
|
+
return finished(row2, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
56693
57250
|
}
|
|
56694
57251
|
if (checkpointedId) {
|
|
56695
57252
|
const entity = await getEntityForReconciliation(
|
|
@@ -56718,13 +57275,13 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56718
57275
|
"The upstream entity did not match the requested safe state",
|
|
56719
57276
|
policy.maxAttempts
|
|
56720
57277
|
);
|
|
56721
|
-
return
|
|
57278
|
+
return finished(row2, { reasonCode: OutcomeReasonCodes.UNKNOWN });
|
|
56722
57279
|
}
|
|
56723
57280
|
const row = finishReconciliation(app, claimed, leaseOwner, context, {
|
|
56724
57281
|
state: AdsOperationStates.succeeded,
|
|
56725
57282
|
entity: match
|
|
56726
57283
|
}, policy.maxAttempts);
|
|
56727
|
-
return
|
|
57284
|
+
return finished(row, { reasonCode: OutcomeReasonCodes.UNKNOWN });
|
|
56728
57285
|
} catch (error) {
|
|
56729
57286
|
const failure = errorDetails(error);
|
|
56730
57287
|
const row = unknownReconciliation(
|
|
@@ -56736,7 +57293,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56736
57293
|
"OpenAI Ads API outcome could not be reconciled",
|
|
56737
57294
|
policy.maxAttempts
|
|
56738
57295
|
);
|
|
56739
|
-
return
|
|
57296
|
+
return finished(row, upstreamFailure(error));
|
|
56740
57297
|
}
|
|
56741
57298
|
}
|
|
56742
57299
|
async function sweepAdsOperationsOnce(app, opts, config) {
|
|
@@ -56808,7 +57365,10 @@ async function sweepAdsOperationsOnce(app, opts, config) {
|
|
|
56808
57365
|
)
|
|
56809
57366
|
)).orderBy(asc12(adsOperations.updatedAt)).limit(config.batchSize).all();
|
|
56810
57367
|
for (const row of exhaustedRows) {
|
|
56811
|
-
quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
|
|
57368
|
+
const settled = quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
|
|
57369
|
+
if (settled.errorCode === ADS_RECONCILIATION_QUARANTINED) {
|
|
57370
|
+
startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", { trigger: OutcomeTriggers.scheduled }).report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57371
|
+
}
|
|
56812
57372
|
}
|
|
56813
57373
|
const credentialsByProjectId = /* @__PURE__ */ new Map();
|
|
56814
57374
|
const projectRows = app.db.selectDistinct({ id: projects.id, name: projects.name }).from(projects).innerJoin(adsOperations, eq60(adsOperations.projectId, projects.id)).where(staleReconcileWhere).all();
|
|
@@ -57002,7 +57562,7 @@ async function adsRoutes(app, opts) {
|
|
|
57002
57562
|
});
|
|
57003
57563
|
app.post(
|
|
57004
57564
|
"/projects/:name/ads/connect",
|
|
57005
|
-
async (request) => {
|
|
57565
|
+
connectionRoute(app, () => ({ integration: "openai_ads", action: "connect" }), async (request, _reply, attempt) => {
|
|
57006
57566
|
requireScope(request, ADS_WRITE_SCOPE);
|
|
57007
57567
|
const project = resolveProject(app.db, request.params.name);
|
|
57008
57568
|
const parsed = adsConnectRequestSchema.safeParse(request.body);
|
|
@@ -57014,12 +57574,14 @@ async function adsRoutes(app, opts) {
|
|
|
57014
57574
|
try {
|
|
57015
57575
|
account = await opts.verifyAdsAccount(parsed.data.apiKey);
|
|
57016
57576
|
} catch (err) {
|
|
57577
|
+
attempt.failed(err);
|
|
57017
57578
|
const message = describeError(err);
|
|
57018
57579
|
throw validationError(`OpenAI Ads API rejected the key: ${message}`);
|
|
57019
57580
|
}
|
|
57020
57581
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
57021
57582
|
verificationCache.delete(project.id);
|
|
57022
57583
|
const existingCfg = opts.adsCredentialStore.getConnection(project.name);
|
|
57584
|
+
if (existingCfg) attempt.update({ action: "reauth" });
|
|
57023
57585
|
opts.adsCredentialStore.upsertConnection({
|
|
57024
57586
|
projectName: project.name,
|
|
57025
57587
|
apiKey: parsed.data.apiKey,
|
|
@@ -57071,9 +57633,9 @@ async function adsRoutes(app, opts) {
|
|
|
57071
57633
|
});
|
|
57072
57634
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
57073
57635
|
return statusDto(row);
|
|
57074
|
-
}
|
|
57636
|
+
})
|
|
57075
57637
|
);
|
|
57076
|
-
app.delete("/projects/:name/ads/connection", async (request) => {
|
|
57638
|
+
app.delete("/projects/:name/ads/connection", connectionRoute(app, () => ({ integration: "openai_ads", action: "disconnect" }), async (request, _reply, attempt) => {
|
|
57077
57639
|
requireScope(request, ADS_WRITE_SCOPE);
|
|
57078
57640
|
const project = resolveProject(app.db, request.params.name);
|
|
57079
57641
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
@@ -57092,8 +57654,9 @@ async function adsRoutes(app, opts) {
|
|
|
57092
57654
|
const removedFromConfig = opts.adsCredentialStore?.removeConnection(project.name) ?? false;
|
|
57093
57655
|
verificationCache.delete(project.id);
|
|
57094
57656
|
const response = { disconnected: Boolean(row) || removedFromConfig };
|
|
57657
|
+
if (!response.disconnected) attempt.cancelled("NOT_CONNECTED");
|
|
57095
57658
|
return response;
|
|
57096
|
-
});
|
|
57659
|
+
}));
|
|
57097
57660
|
app.get("/projects/:name/ads/status", async (request) => {
|
|
57098
57661
|
const project = resolveProject(app.db, request.params.name);
|
|
57099
57662
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
@@ -58426,7 +58989,7 @@ async function bingRoutes(app, opts) {
|
|
|
58426
58989
|
}
|
|
58427
58990
|
return conn;
|
|
58428
58991
|
}
|
|
58429
|
-
app.post("/projects/:name/bing/connect", async (request) => {
|
|
58992
|
+
app.post("/projects/:name/bing/connect", connectionRoute(app, () => ({ integration: "bing", action: "connect" }), async (request, _reply, attempt) => {
|
|
58430
58993
|
const store = requireConnectionStore();
|
|
58431
58994
|
const { apiKey } = request.body ?? {};
|
|
58432
58995
|
if (!apiKey || typeof apiKey !== "string") {
|
|
@@ -58440,11 +59003,14 @@ async function bingRoutes(app, opts) {
|
|
|
58440
59003
|
} catch (e) {
|
|
58441
59004
|
const msg = describeError(e);
|
|
58442
59005
|
bingLog("error", "connect.verify-key-failed", { domain: project.canonicalDomain, error: msg });
|
|
59006
|
+
attempt.failed(e);
|
|
58443
59007
|
throw validationError(`Failed to verify Bing API key: ${msg}`);
|
|
58444
59008
|
}
|
|
58445
59009
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
58446
59010
|
const existing = store.getConnection(project.canonicalDomain);
|
|
59011
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
58447
59012
|
if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
59013
|
+
attempt.failed(void 0, "ALREADY_CONNECTED");
|
|
58448
59014
|
throw validationError(
|
|
58449
59015
|
`This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
|
|
58450
59016
|
);
|
|
@@ -58470,12 +59036,13 @@ async function bingRoutes(app, opts) {
|
|
|
58470
59036
|
siteUrl: existing?.siteUrl ?? null,
|
|
58471
59037
|
availableSites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false }))
|
|
58472
59038
|
};
|
|
58473
|
-
});
|
|
58474
|
-
app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
|
|
59039
|
+
}));
|
|
59040
|
+
app.delete("/projects/:name/bing/disconnect", connectionRoute(app, () => ({ integration: "bing", action: "disconnect" }), async (request, reply, attempt) => {
|
|
58475
59041
|
const store = requireConnectionStore();
|
|
58476
59042
|
const project = resolveProject(app.db, request.params.name);
|
|
58477
59043
|
const existing = store.getConnection(project.canonicalDomain);
|
|
58478
59044
|
if (!existing) {
|
|
59045
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
58479
59046
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
58480
59047
|
}
|
|
58481
59048
|
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
@@ -58485,6 +59052,7 @@ async function bingRoutes(app, opts) {
|
|
|
58485
59052
|
}
|
|
58486
59053
|
const deleted = store.deleteConnection(project.canonicalDomain);
|
|
58487
59054
|
if (!deleted) {
|
|
59055
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
58488
59056
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
58489
59057
|
}
|
|
58490
59058
|
writeAuditLog(app.db, {
|
|
@@ -58495,7 +59063,7 @@ async function bingRoutes(app, opts) {
|
|
|
58495
59063
|
entityId: project.canonicalDomain
|
|
58496
59064
|
});
|
|
58497
59065
|
return reply.status(204).send();
|
|
58498
|
-
});
|
|
59066
|
+
}));
|
|
58499
59067
|
app.get("/projects/:name/bing/status", async (request) => {
|
|
58500
59068
|
const store = requireConnectionStore();
|
|
58501
59069
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -58516,10 +59084,15 @@ async function bingRoutes(app, opts) {
|
|
|
58516
59084
|
const sites = await getSites(conn.apiKey);
|
|
58517
59085
|
return { sites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false })) };
|
|
58518
59086
|
});
|
|
58519
|
-
app.post("/projects/:name/bing/set-site", async (request) => {
|
|
59087
|
+
app.post("/projects/:name/bing/set-site", connectionRoute(app, () => ({ integration: "bing", action: "select" }), async (request, _reply, attempt) => {
|
|
58520
59088
|
const store = requireConnectionStore();
|
|
58521
59089
|
const project = resolveProject(app.db, request.params.name);
|
|
58522
|
-
|
|
59090
|
+
try {
|
|
59091
|
+
requireConnection(store, project.canonicalDomain);
|
|
59092
|
+
} catch (err) {
|
|
59093
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
59094
|
+
throw err;
|
|
59095
|
+
}
|
|
58523
59096
|
const { siteUrl } = request.body ?? {};
|
|
58524
59097
|
if (!siteUrl || typeof siteUrl !== "string") {
|
|
58525
59098
|
throw validationError("siteUrl is required");
|
|
@@ -58529,7 +59102,7 @@ async function bingRoutes(app, opts) {
|
|
|
58529
59102
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
58530
59103
|
});
|
|
58531
59104
|
return { siteUrl };
|
|
58532
|
-
});
|
|
59105
|
+
}));
|
|
58533
59106
|
app.get("/projects/:name/bing/coverage", async (request) => {
|
|
58534
59107
|
const store = requireConnectionStore();
|
|
58535
59108
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -58653,9 +59226,13 @@ async function bingRoutes(app, opts) {
|
|
|
58653
59226
|
app.post("/projects/:name/bing/inspect-url", async (request) => {
|
|
58654
59227
|
const store = requireConnectionStore();
|
|
58655
59228
|
const project = resolveProject(app.db, request.params.name);
|
|
58656
|
-
const
|
|
59229
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.bing, "inspect");
|
|
59230
|
+
const conn = outcome2.guard(OutcomeReasonCodes.NOT_CONNECTED, () => requireConnection(store, project.canonicalDomain));
|
|
58657
59231
|
if (!conn.siteUrl) {
|
|
58658
|
-
throw
|
|
59232
|
+
throw outcome2.refuse(
|
|
59233
|
+
OutcomeReasonCodes.PROPERTY_NOT_FOUND,
|
|
59234
|
+
validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.')
|
|
59235
|
+
);
|
|
58659
59236
|
}
|
|
58660
59237
|
const { url } = request.body ?? {};
|
|
58661
59238
|
if (!url) {
|
|
@@ -58726,6 +59303,7 @@ async function bingRoutes(app, opts) {
|
|
|
58726
59303
|
discoveryDate
|
|
58727
59304
|
}).run();
|
|
58728
59305
|
app.db.update(runs).set({ status: RunStatuses.completed, finishedAt: now }).where(eq61(runs.id, runId)).run();
|
|
59306
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
|
|
58729
59307
|
return {
|
|
58730
59308
|
id,
|
|
58731
59309
|
url,
|
|
@@ -58742,6 +59320,12 @@ async function bingRoutes(app, opts) {
|
|
|
58742
59320
|
const msg = describeError(e);
|
|
58743
59321
|
bingLog("error", "inspect-url.failed", { domain: project.canonicalDomain, url, error: msg });
|
|
58744
59322
|
app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq61(runs.id, runId)).run();
|
|
59323
|
+
const failure = classifyOutcomeError(e);
|
|
59324
|
+
outcome2.report({
|
|
59325
|
+
status: OutcomeStatuses.failed,
|
|
59326
|
+
...failure,
|
|
59327
|
+
...e instanceof BingApiError && e.isThrottle ? { reasonCode: OutcomeReasonCodes.RATE_LIMITED } : {}
|
|
59328
|
+
});
|
|
58745
59329
|
throw e;
|
|
58746
59330
|
}
|
|
58747
59331
|
});
|
|
@@ -58893,35 +59477,48 @@ async function cdpRoutes(app, opts) {
|
|
|
58893
59477
|
const stream = fs.createReadStream(fullPath);
|
|
58894
59478
|
return reply.type("image/png").send(stream);
|
|
58895
59479
|
});
|
|
58896
|
-
app.put("/settings/cdp", async (request, reply) => {
|
|
59480
|
+
app.put("/settings/cdp", connectionRoute(app, () => ({ integration: "cdp", action: "connect" }), async (request, reply, attempt) => {
|
|
58897
59481
|
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
58898
59482
|
if (!opts.onCdpConfigure) {
|
|
58899
59483
|
const err = notImplemented("CDP configuration not supported in this deployment");
|
|
59484
|
+
attempt.failed(err);
|
|
58900
59485
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58901
59486
|
}
|
|
58902
59487
|
const { host, port = 9222 } = request.body;
|
|
58903
59488
|
if (!host || typeof host !== "string") {
|
|
58904
59489
|
const err = validationError("host is required");
|
|
59490
|
+
attempt.failed(err);
|
|
58905
59491
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58906
59492
|
}
|
|
58907
59493
|
const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
|
|
58908
59494
|
if (!ALLOWED_HOSTS.includes(host)) {
|
|
58909
59495
|
const err = validationError("host must be localhost, 127.0.0.1, or ::1");
|
|
59496
|
+
attempt.failed(err, "BLOCKED_UNSAFE_URL");
|
|
58910
59497
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58911
59498
|
}
|
|
58912
59499
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
58913
59500
|
const err = validationError("port must be an integer between 1 and 65535");
|
|
59501
|
+
attempt.failed(err);
|
|
58914
59502
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58915
59503
|
}
|
|
58916
59504
|
await opts.onCdpConfigure(host, port);
|
|
58917
59505
|
return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
|
|
58918
|
-
});
|
|
59506
|
+
}));
|
|
58919
59507
|
app.get("/cdp/status", async (_request, reply) => {
|
|
58920
59508
|
if (!opts.getCdpStatus) {
|
|
58921
59509
|
const err = notImplemented("CDP not configured");
|
|
58922
59510
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58923
59511
|
}
|
|
58924
|
-
const
|
|
59512
|
+
const attempt = startConnectionAttempt(app, { integration: "cdp", action: "test" });
|
|
59513
|
+
let status;
|
|
59514
|
+
try {
|
|
59515
|
+
status = await opts.getCdpStatus();
|
|
59516
|
+
} catch (err) {
|
|
59517
|
+
attempt.failed(err);
|
|
59518
|
+
throw err;
|
|
59519
|
+
}
|
|
59520
|
+
if (status.connected) attempt.succeeded();
|
|
59521
|
+
else if (status.endpoint) attempt.failed(void 0, "NETWORK");
|
|
58925
59522
|
return reply.send(status);
|
|
58926
59523
|
});
|
|
58927
59524
|
app.post("/cdp/screenshot", async (request, reply) => {
|
|
@@ -59546,6 +60143,17 @@ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
|
|
|
59546
60143
|
});
|
|
59547
60144
|
return { accessToken, propertyId: oauthConn.propertyId };
|
|
59548
60145
|
}
|
|
60146
|
+
function ga4SyncFailure(err, credentialsResolved, opts, project) {
|
|
60147
|
+
const classified = classifyOutcomeError(err);
|
|
60148
|
+
if (credentialsResolved) {
|
|
60149
|
+
return err instanceof GA4ApiError && err.status === 404 ? { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND } : classified;
|
|
60150
|
+
}
|
|
60151
|
+
if (!(err instanceof AppError)) return credentialFailure(err);
|
|
60152
|
+
const oauth = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
60153
|
+
const serviceAccount = opts.ga4CredentialStore?.getConnection(project.name);
|
|
60154
|
+
const unselected = Boolean(oauth?.refreshToken && !oauth.propertyId) || Boolean(serviceAccount?.clientEmail && !serviceAccount.propertyId);
|
|
60155
|
+
return { ...classified, reasonCode: unselected ? OutcomeReasonCodes.PROPERTY_NOT_FOUND : OutcomeReasonCodes.NOT_CONNECTED };
|
|
60156
|
+
}
|
|
59549
60157
|
function requireGa4Connection(opts, projectName, canonicalDomain) {
|
|
59550
60158
|
const saConn = opts.ga4CredentialStore?.getConnection(projectName);
|
|
59551
60159
|
const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
|
|
@@ -59554,7 +60162,10 @@ function requireGa4Connection(opts, projectName, canonicalDomain) {
|
|
|
59554
60162
|
}
|
|
59555
60163
|
}
|
|
59556
60164
|
async function ga4Routes(app, opts) {
|
|
59557
|
-
app.post("/projects/:name/ga/connect",
|
|
60165
|
+
app.post("/projects/:name/ga/connect", connectionRoute(app, (request) => {
|
|
60166
|
+
const body = request.body;
|
|
60167
|
+
return { integration: "ga4", action: typeof body?.keyJson === "string" && body.keyJson ? "connect" : "select" };
|
|
60168
|
+
}, async (request, _reply, attempt) => {
|
|
59558
60169
|
const project = resolveProject(app.db, request.params.name);
|
|
59559
60170
|
const { propertyId, keyJson } = request.body ?? {};
|
|
59560
60171
|
if (!propertyId || typeof propertyId !== "string") {
|
|
@@ -59581,10 +60192,12 @@ async function ga4Routes(app, opts) {
|
|
|
59581
60192
|
} catch (e) {
|
|
59582
60193
|
const msg = describeError(e);
|
|
59583
60194
|
gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
|
|
60195
|
+
attempt.failed(e);
|
|
59584
60196
|
throw validationError(`Failed to verify GA4 credentials: ${msg}`);
|
|
59585
60197
|
}
|
|
59586
60198
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
59587
60199
|
const existing = opts.ga4CredentialStore.getConnection(project.name);
|
|
60200
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
59588
60201
|
const previousPropertyId2 = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
|
|
59589
60202
|
opts.ga4CredentialStore.upsertConnection({
|
|
59590
60203
|
projectName: project.name,
|
|
@@ -59616,6 +60229,7 @@ async function ga4Routes(app, opts) {
|
|
|
59616
60229
|
}
|
|
59617
60230
|
const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
|
|
59618
60231
|
if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
|
|
60232
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
59619
60233
|
throw validationError(
|
|
59620
60234
|
'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
|
|
59621
60235
|
);
|
|
@@ -59631,6 +60245,7 @@ async function ga4Routes(app, opts) {
|
|
|
59631
60245
|
} catch (e) {
|
|
59632
60246
|
const msg = describeError(e);
|
|
59633
60247
|
gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
|
|
60248
|
+
attempt.failed(e);
|
|
59634
60249
|
throw validationError(`Failed to verify GA4 access: ${msg}`);
|
|
59635
60250
|
}
|
|
59636
60251
|
const previousPropertyId = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
|
|
@@ -59651,12 +60266,13 @@ async function ga4Routes(app, opts) {
|
|
|
59651
60266
|
entityId: propertyId
|
|
59652
60267
|
});
|
|
59653
60268
|
return { connected: true, propertyId, authMethod: "oauth" };
|
|
59654
|
-
});
|
|
59655
|
-
app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
|
|
60269
|
+
}));
|
|
60270
|
+
app.delete("/projects/:name/ga/disconnect", connectionRoute(app, () => ({ integration: "ga4", action: "disconnect" }), async (request, reply, attempt) => {
|
|
59656
60271
|
const project = resolveProject(app.db, request.params.name);
|
|
59657
60272
|
const saConn = opts.ga4CredentialStore?.getConnection(project.name);
|
|
59658
60273
|
const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
59659
60274
|
if (!saConn && !oauthConn) {
|
|
60275
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
59660
60276
|
throw notFound("GA4 connection", project.name);
|
|
59661
60277
|
}
|
|
59662
60278
|
app.db.delete(gaTrafficSnapshots).where(eq64(gaTrafficSnapshots.projectId, project.id)).run();
|
|
@@ -59676,7 +60292,7 @@ async function ga4Routes(app, opts) {
|
|
|
59676
60292
|
entityId: propertyId
|
|
59677
60293
|
});
|
|
59678
60294
|
return reply.status(204).send();
|
|
59679
|
-
});
|
|
60295
|
+
}));
|
|
59680
60296
|
app.get("/projects/:name/ga/properties", async (request) => {
|
|
59681
60297
|
assertNotProjectScoped(request, "listing GA4 properties");
|
|
59682
60298
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -59750,8 +60366,12 @@ async function ga4Routes(app, opts) {
|
|
|
59750
60366
|
startedAt,
|
|
59751
60367
|
createdAt: startedAt
|
|
59752
60368
|
}).run();
|
|
60369
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.ga4, "sync");
|
|
60370
|
+
let credentialsResolved = false;
|
|
60371
|
+
let componentFailure;
|
|
59753
60372
|
try {
|
|
59754
60373
|
const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
|
|
60374
|
+
credentialsResolved = true;
|
|
59755
60375
|
let rows = [];
|
|
59756
60376
|
let aiReferrals = [];
|
|
59757
60377
|
let socialReferrals = [];
|
|
@@ -59924,6 +60544,7 @@ async function ga4Routes(app, opts) {
|
|
|
59924
60544
|
const [acquisitionSettled, leadsSettled, searchLandingSettled] = await measurementFetch;
|
|
59925
60545
|
const measurementNow = (/* @__PURE__ */ new Date()).toISOString();
|
|
59926
60546
|
const recordMeasurementFailure = (component, reason) => {
|
|
60547
|
+
componentFailure ??= { error: reason };
|
|
59927
60548
|
const message = describeError(reason);
|
|
59928
60549
|
gaLog("warn", "measurement.component-failed", {
|
|
59929
60550
|
projectId: project.id,
|
|
@@ -59989,6 +60610,7 @@ async function ga4Routes(app, opts) {
|
|
|
59989
60610
|
}
|
|
59990
60611
|
})();
|
|
59991
60612
|
const recordSearchLandingFailure = (status, reason) => {
|
|
60613
|
+
if (status === "error") componentFailure ??= { error: reason };
|
|
59992
60614
|
const message = describeError(reason);
|
|
59993
60615
|
gaLog("warn", "search-landing.component-failed", {
|
|
59994
60616
|
projectId: project.id,
|
|
@@ -60047,6 +60669,14 @@ async function ga4Routes(app, opts) {
|
|
|
60047
60669
|
totalUsers: summary3.totalUsers,
|
|
60048
60670
|
...only ? { only } : {}
|
|
60049
60671
|
});
|
|
60672
|
+
const counts2 = { rows: rows.length + dailyTotals.length + aiReferrals.length + socialReferrals.length };
|
|
60673
|
+
if (componentFailure) {
|
|
60674
|
+
outcome2.report({ status: OutcomeStatuses.partial, ...classifyOutcomeError(componentFailure.error), counts: counts2 });
|
|
60675
|
+
} else if (rows.length === 0 && dailyTotals.length === 0) {
|
|
60676
|
+
outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA, counts: counts2 });
|
|
60677
|
+
} else {
|
|
60678
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: counts2 });
|
|
60679
|
+
}
|
|
60050
60680
|
return {
|
|
60051
60681
|
synced: true,
|
|
60052
60682
|
rowCount: rows.length,
|
|
@@ -60067,6 +60697,7 @@ async function ga4Routes(app, opts) {
|
|
|
60067
60697
|
const msg = describeError(e);
|
|
60068
60698
|
gaLog("error", "sync.fetch-failed", { projectId: project.id, runId, error: msg });
|
|
60069
60699
|
app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq64(runs.id, runId)).run();
|
|
60700
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...ga4SyncFailure(e, credentialsResolved, opts, project) });
|
|
60070
60701
|
throw e;
|
|
60071
60702
|
}
|
|
60072
60703
|
});
|
|
@@ -61706,16 +62337,43 @@ function toAppError(error) {
|
|
|
61706
62337
|
return providerError(error.message, { statusCode: error.statusCode });
|
|
61707
62338
|
}
|
|
61708
62339
|
}
|
|
61709
|
-
|
|
62340
|
+
function wordpressFailureReason(error) {
|
|
62341
|
+
if (error instanceof EgressRefusedError) return "BLOCKED_UNSAFE_URL";
|
|
62342
|
+
if (error instanceof EgressFailedError) return "NETWORK";
|
|
62343
|
+
return void 0;
|
|
62344
|
+
}
|
|
62345
|
+
async function withWordpressErrorHandling(handler, attempt) {
|
|
61710
62346
|
try {
|
|
61711
62347
|
return await handler();
|
|
61712
62348
|
} catch (error) {
|
|
62349
|
+
attempt?.failed(error, wordpressFailureReason(error));
|
|
61713
62350
|
if (error instanceof WordpressApiError) throw toAppError(error);
|
|
61714
62351
|
if (error instanceof EgressRefusedError && !error.redirected) throw validationError(error.message);
|
|
61715
62352
|
if (error instanceof EgressRefusedError || error instanceof EgressFailedError) throw providerError(error.message);
|
|
61716
62353
|
throw error;
|
|
61717
62354
|
}
|
|
61718
62355
|
}
|
|
62356
|
+
var MANUAL_ASSIST_OUTCOME = { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.UNSUPPORTED };
|
|
62357
|
+
function metaWriteOutcome(results) {
|
|
62358
|
+
const applied = results.filter((r) => r.status === "applied").length;
|
|
62359
|
+
const manual = results.filter((r) => r.status === "manual").length;
|
|
62360
|
+
const errored = results.filter((r) => r.status === "skipped").length;
|
|
62361
|
+
const counts2 = { items: applied, skipped: manual, failures: errored };
|
|
62362
|
+
if (manual === 0 && errored === 0) return { status: OutcomeStatuses.succeeded, counts: counts2 };
|
|
62363
|
+
const reasonCode = manual > 0 ? OutcomeReasonCodes.UNSUPPORTED : OutcomeReasonCodes.UNKNOWN;
|
|
62364
|
+
if (applied > 0) return { status: OutcomeStatuses.partial, reasonCode, counts: counts2 };
|
|
62365
|
+
return { status: errored > 0 ? OutcomeStatuses.failed : OutcomeStatuses.skipped, reasonCode, counts: counts2 };
|
|
62366
|
+
}
|
|
62367
|
+
function schemaDeployOutcome(results) {
|
|
62368
|
+
const deployed = results.filter((r) => r.status === "deployed").length;
|
|
62369
|
+
const stripped = results.filter((r) => r.status === "stripped").length;
|
|
62370
|
+
const missing = results.filter((r) => r.status === "skipped").length;
|
|
62371
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
62372
|
+
const counts2 = { items: deployed, skipped: stripped + missing, failures: failed };
|
|
62373
|
+
if (deployed === results.length) return { status: OutcomeStatuses.succeeded, counts: counts2 };
|
|
62374
|
+
const reasonCode = stripped > 0 ? OutcomeReasonCodes.PERMISSION_MISSING : failed > 0 ? OutcomeReasonCodes.UNKNOWN : OutcomeReasonCodes.NOT_FOUND;
|
|
62375
|
+
return { status: deployed > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, reasonCode, counts: counts2 };
|
|
62376
|
+
}
|
|
61719
62377
|
async function wordpressRoutes(app, opts) {
|
|
61720
62378
|
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
61721
62379
|
function requireStore() {
|
|
@@ -61726,20 +62384,22 @@ async function wordpressRoutes(app, opts) {
|
|
|
61726
62384
|
function guarded(connection) {
|
|
61727
62385
|
return { ...connection, fetchImpl: egressFetch };
|
|
61728
62386
|
}
|
|
61729
|
-
async function assertWordpressUrlAllowed(rawUrl, field) {
|
|
62387
|
+
async function assertWordpressUrlAllowed(rawUrl, field, attempt) {
|
|
61730
62388
|
const check2 = await resolveWebhookTarget(rawUrl, { allowLoopback });
|
|
61731
62389
|
if (!check2.ok) {
|
|
62390
|
+
attempt?.failed(void 0, webhookTargetRefusalReason(check2));
|
|
61732
62391
|
throw validationError(`${field} ${check2.message.replace(/^"url" /, "")}`);
|
|
61733
62392
|
}
|
|
61734
62393
|
}
|
|
61735
|
-
function requireConnection(store, projectName) {
|
|
62394
|
+
function requireConnection(store, projectName, settle) {
|
|
61736
62395
|
const connection = store.getConnection(projectName);
|
|
61737
62396
|
if (!connection) {
|
|
62397
|
+
settle?.({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED });
|
|
61738
62398
|
throw validationError(`No WordPress connection found for project "${projectName}". Run "canonry wordpress connect ${projectName}" first.`);
|
|
61739
62399
|
}
|
|
61740
62400
|
return guarded(connection);
|
|
61741
62401
|
}
|
|
61742
|
-
app.post("/projects/:name/wordpress/connect", async (request) => {
|
|
62402
|
+
app.post("/projects/:name/wordpress/connect", connectionRoute(app, () => ({ integration: "wordpress", action: "connect" }), async (request, _reply, attempt) => {
|
|
61743
62403
|
return withWordpressErrorHandling(async () => {
|
|
61744
62404
|
const store = requireStore();
|
|
61745
62405
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -61751,10 +62411,11 @@ async function wordpressRoutes(app, opts) {
|
|
|
61751
62411
|
if (defaultEnv === "staging" && !stagingUrl) {
|
|
61752
62412
|
throw validationError('defaultEnv "staging" requires stagingUrl');
|
|
61753
62413
|
}
|
|
61754
|
-
await assertWordpressUrlAllowed(url, "url");
|
|
61755
|
-
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
|
|
62414
|
+
await assertWordpressUrlAllowed(url, "url", attempt);
|
|
62415
|
+
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
|
|
61756
62416
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
61757
62417
|
const existing = store.getConnection(project.name);
|
|
62418
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
61758
62419
|
const nextConnection = {
|
|
61759
62420
|
projectName: project.name,
|
|
61760
62421
|
url,
|
|
@@ -61784,13 +62445,14 @@ async function wordpressRoutes(app, opts) {
|
|
|
61784
62445
|
staging,
|
|
61785
62446
|
adminUrl: getWpStagingAdminUrl(connection.url)
|
|
61786
62447
|
};
|
|
61787
|
-
});
|
|
61788
|
-
});
|
|
61789
|
-
app.delete("/projects/:name/wordpress/disconnect", async (request, reply) => {
|
|
62448
|
+
}, attempt);
|
|
62449
|
+
}));
|
|
62450
|
+
app.delete("/projects/:name/wordpress/disconnect", connectionRoute(app, () => ({ integration: "wordpress", action: "disconnect" }), async (request, reply, attempt) => {
|
|
61790
62451
|
const store = requireStore();
|
|
61791
62452
|
const project = resolveProject(app.db, request.params.name);
|
|
61792
62453
|
const deleted = store.deleteConnection(project.name);
|
|
61793
62454
|
if (!deleted) {
|
|
62455
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
61794
62456
|
throw notFound("WordPress connection", project.name);
|
|
61795
62457
|
}
|
|
61796
62458
|
writeAuditLog(app.db, {
|
|
@@ -61801,7 +62463,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
61801
62463
|
entityId: project.name
|
|
61802
62464
|
});
|
|
61803
62465
|
return reply.status(204).send();
|
|
61804
|
-
});
|
|
62466
|
+
}));
|
|
61805
62467
|
app.get("/projects/:name/wordpress/status", async (request) => {
|
|
61806
62468
|
const project = resolveProject(app.db, request.params.name);
|
|
61807
62469
|
const stored = opts.wordpressConnectionStore?.getConnection(project.name);
|
|
@@ -61853,10 +62515,10 @@ async function wordpressRoutes(app, opts) {
|
|
|
61853
62515
|
});
|
|
61854
62516
|
});
|
|
61855
62517
|
app.post("/projects/:name/wordpress/pages", async (request) => {
|
|
61856
|
-
return withWordpressErrorHandling(async () => {
|
|
62518
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
61857
62519
|
const store = requireStore();
|
|
61858
62520
|
const project = resolveProject(app.db, request.params.name);
|
|
61859
|
-
const connection = requireConnection(store, project.name);
|
|
62521
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61860
62522
|
const { title, slug, content, status } = request.body ?? {};
|
|
61861
62523
|
const env = parseEnvInput(request.body?.env);
|
|
61862
62524
|
if (!title || !slug || !content) {
|
|
@@ -61870,14 +62532,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61870
62532
|
entityType: "wordpress_page",
|
|
61871
62533
|
entityId: created.slug
|
|
61872
62534
|
});
|
|
62535
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61873
62536
|
return created;
|
|
61874
|
-
});
|
|
62537
|
+
}, wordpressFailureReason));
|
|
61875
62538
|
});
|
|
61876
62539
|
app.put("/projects/:name/wordpress/page", async (request) => {
|
|
61877
|
-
return withWordpressErrorHandling(async () => {
|
|
62540
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
61878
62541
|
const store = requireStore();
|
|
61879
62542
|
const project = resolveProject(app.db, request.params.name);
|
|
61880
|
-
const connection = requireConnection(store, project.name);
|
|
62543
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61881
62544
|
const currentSlug = request.body?.currentSlug?.trim();
|
|
61882
62545
|
if (!currentSlug) {
|
|
61883
62546
|
throw validationError("currentSlug is required");
|
|
@@ -61896,14 +62559,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61896
62559
|
entityType: "wordpress_page",
|
|
61897
62560
|
entityId: currentSlug
|
|
61898
62561
|
});
|
|
62562
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61899
62563
|
return updated;
|
|
61900
|
-
});
|
|
62564
|
+
}, wordpressFailureReason));
|
|
61901
62565
|
});
|
|
61902
62566
|
app.post("/projects/:name/wordpress/page/meta", async (request) => {
|
|
61903
|
-
return withWordpressErrorHandling(async () => {
|
|
62567
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
|
|
61904
62568
|
const store = requireStore();
|
|
61905
62569
|
const project = resolveProject(app.db, request.params.name);
|
|
61906
|
-
const connection = requireConnection(store, project.name);
|
|
62570
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61907
62571
|
const slug = request.body?.slug?.trim();
|
|
61908
62572
|
if (!slug) {
|
|
61909
62573
|
throw validationError("slug is required");
|
|
@@ -61921,14 +62585,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61921
62585
|
entityType: "wordpress_page",
|
|
61922
62586
|
entityId: slug
|
|
61923
62587
|
});
|
|
62588
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61924
62589
|
return updated;
|
|
61925
|
-
});
|
|
62590
|
+
}, wordpressFailureReason));
|
|
61926
62591
|
});
|
|
61927
62592
|
app.post("/projects/:name/wordpress/pages/meta/bulk", async (request) => {
|
|
61928
|
-
return withWordpressErrorHandling(async () => {
|
|
62593
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
|
|
61929
62594
|
const store = requireStore();
|
|
61930
62595
|
const project = resolveProject(app.db, request.params.name);
|
|
61931
|
-
const connection = requireConnection(store, project.name);
|
|
62596
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61932
62597
|
const entries = request.body?.entries;
|
|
61933
62598
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
61934
62599
|
throw validationError("entries array is required and must not be empty");
|
|
@@ -61950,8 +62615,9 @@ async function wordpressRoutes(app, opts) {
|
|
|
61950
62615
|
entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
|
|
61951
62616
|
});
|
|
61952
62617
|
}
|
|
62618
|
+
settle(metaWriteOutcome(result.results));
|
|
61953
62619
|
return result;
|
|
61954
|
-
});
|
|
62620
|
+
}, wordpressFailureReason));
|
|
61955
62621
|
});
|
|
61956
62622
|
app.get("/projects/:name/wordpress/schema", async (request) => {
|
|
61957
62623
|
return withWordpressErrorHandling(async () => {
|
|
@@ -61967,31 +62633,35 @@ async function wordpressRoutes(app, opts) {
|
|
|
61967
62633
|
});
|
|
61968
62634
|
});
|
|
61969
62635
|
app.post("/projects/:name/wordpress/schema/manual", async (request) => {
|
|
61970
|
-
return withWordpressErrorHandling(async () => {
|
|
62636
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
|
|
61971
62637
|
const store = requireStore();
|
|
61972
62638
|
const project = resolveProject(app.db, request.params.name);
|
|
61973
|
-
const connection = requireConnection(store, project.name);
|
|
62639
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61974
62640
|
const slug = request.body?.slug?.trim();
|
|
61975
62641
|
const json = request.body?.json;
|
|
61976
62642
|
if (!slug || !json) {
|
|
61977
62643
|
throw validationError("slug and json are required");
|
|
61978
62644
|
}
|
|
61979
62645
|
const env = parseEnvInput(request.body?.env);
|
|
61980
|
-
|
|
61981
|
-
|
|
62646
|
+
const manual = await buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
|
|
62647
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62648
|
+
return manual;
|
|
62649
|
+
}, wordpressFailureReason));
|
|
61982
62650
|
});
|
|
61983
62651
|
app.post("/projects/:name/wordpress/schema/deploy", async (request) => {
|
|
61984
|
-
return withWordpressErrorHandling(async () => {
|
|
62652
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
|
|
61985
62653
|
const store = requireStore();
|
|
61986
62654
|
const project = resolveProject(app.db, request.params.name);
|
|
61987
|
-
const connection = requireConnection(store, project.name);
|
|
62655
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61988
62656
|
const profile = request.body?.profile;
|
|
61989
62657
|
if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
|
|
61990
62658
|
throw validationError("profile with business.name and non-empty pages is required");
|
|
61991
62659
|
}
|
|
61992
62660
|
const env = parseEnvInput(request.body?.env);
|
|
61993
|
-
|
|
61994
|
-
|
|
62661
|
+
const deployed = await deploySchemaFromProfile(connection, profile, env);
|
|
62662
|
+
settle(schemaDeployOutcome(deployed.results));
|
|
62663
|
+
return deployed;
|
|
62664
|
+
}, wordpressFailureReason));
|
|
61995
62665
|
});
|
|
61996
62666
|
app.get("/projects/:name/wordpress/schema/status", async (request) => {
|
|
61997
62667
|
return withWordpressErrorHandling(async () => {
|
|
@@ -62012,17 +62682,19 @@ async function wordpressRoutes(app, opts) {
|
|
|
62012
62682
|
});
|
|
62013
62683
|
});
|
|
62014
62684
|
app.post("/projects/:name/wordpress/llms-txt/manual", async (request) => {
|
|
62015
|
-
return withWordpressErrorHandling(async () => {
|
|
62685
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "llms_txt" }, async (settle) => {
|
|
62016
62686
|
const store = requireStore();
|
|
62017
62687
|
const project = resolveProject(app.db, request.params.name);
|
|
62018
|
-
const connection = requireConnection(store, project.name);
|
|
62688
|
+
const connection = requireConnection(store, project.name, settle);
|
|
62019
62689
|
const content = request.body?.content;
|
|
62020
62690
|
if (!content) {
|
|
62021
62691
|
throw validationError("content is required");
|
|
62022
62692
|
}
|
|
62023
62693
|
const env = parseEnvInput(request.body?.env);
|
|
62024
|
-
|
|
62025
|
-
|
|
62694
|
+
const manual = await buildManualLlmsTxtUpdate(connection, content, env);
|
|
62695
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62696
|
+
return manual;
|
|
62697
|
+
}, wordpressFailureReason));
|
|
62026
62698
|
});
|
|
62027
62699
|
app.get("/projects/:name/wordpress/audit", async (request) => {
|
|
62028
62700
|
return withWordpressErrorHandling(async () => {
|
|
@@ -62060,17 +62732,20 @@ async function wordpressRoutes(app, opts) {
|
|
|
62060
62732
|
});
|
|
62061
62733
|
});
|
|
62062
62734
|
app.post("/projects/:name/wordpress/staging/push", async (request) => {
|
|
62063
|
-
return withWordpressErrorHandling(async () => {
|
|
62735
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
62064
62736
|
const store = requireStore();
|
|
62065
62737
|
const project = resolveProject(app.db, request.params.name);
|
|
62066
|
-
const connection = requireConnection(store, project.name);
|
|
62738
|
+
const connection = requireConnection(store, project.name, settle);
|
|
62067
62739
|
if (!connection.stagingUrl) {
|
|
62068
62740
|
throw validationError("No staging URL configured for this project. Reconnect with --staging-url before using staging push.");
|
|
62069
62741
|
}
|
|
62070
|
-
|
|
62071
|
-
|
|
62742
|
+
const manual = await buildManualStagingPush(connection);
|
|
62743
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62744
|
+
return manual;
|
|
62745
|
+
}, wordpressFailureReason));
|
|
62072
62746
|
});
|
|
62073
62747
|
app.post("/projects/:name/wordpress/onboard", async (request) => {
|
|
62748
|
+
const attempt = startConnectionAttempt(app, { integration: "wordpress", action: "connect" });
|
|
62074
62749
|
return withWordpressErrorHandling(async () => {
|
|
62075
62750
|
const store = requireStore();
|
|
62076
62751
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -62082,14 +62757,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
62082
62757
|
if (defaultEnv === "staging" && !stagingUrl) {
|
|
62083
62758
|
throw validationError('defaultEnv "staging" requires stagingUrl');
|
|
62084
62759
|
}
|
|
62085
|
-
await assertWordpressUrlAllowed(url, "url");
|
|
62086
|
-
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
|
|
62760
|
+
await assertWordpressUrlAllowed(url, "url", attempt);
|
|
62761
|
+
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
|
|
62087
62762
|
const steps = [];
|
|
62088
62763
|
let connection = null;
|
|
62089
62764
|
let pageUrls = [];
|
|
62090
62765
|
try {
|
|
62091
62766
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
62092
62767
|
const existing = store.getConnection(project.name);
|
|
62768
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
62093
62769
|
const nextConnection = {
|
|
62094
62770
|
projectName: project.name,
|
|
62095
62771
|
url,
|
|
@@ -62110,7 +62786,9 @@ async function wordpressRoutes(app, opts) {
|
|
|
62110
62786
|
entityId: project.name
|
|
62111
62787
|
});
|
|
62112
62788
|
steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
|
|
62789
|
+
attempt.succeeded();
|
|
62113
62790
|
} catch (err) {
|
|
62791
|
+
attempt.failed(err, wordpressFailureReason(err));
|
|
62114
62792
|
const msg = describeError(err);
|
|
62115
62793
|
steps.push({ name: "connect", status: "failed", error: msg });
|
|
62116
62794
|
return { projectName: project.name, steps };
|
|
@@ -62131,6 +62809,11 @@ async function wordpressRoutes(app, opts) {
|
|
|
62131
62809
|
steps.push({ name: "audit", status: "failed", error: msg });
|
|
62132
62810
|
return { projectName: project.name, steps };
|
|
62133
62811
|
}
|
|
62812
|
+
const metaStartedAt = Date.now();
|
|
62813
|
+
const reportMetaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, {
|
|
62814
|
+
...outcome2,
|
|
62815
|
+
durationBucket: bucketDuration(Date.now() - metaStartedAt)
|
|
62816
|
+
});
|
|
62134
62817
|
try {
|
|
62135
62818
|
const metaEntries = [];
|
|
62136
62819
|
for (const issue of auditIssues) {
|
|
@@ -62155,8 +62838,10 @@ async function wordpressRoutes(app, opts) {
|
|
|
62155
62838
|
}
|
|
62156
62839
|
if (metaEntries.length === 0) {
|
|
62157
62840
|
steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
|
|
62841
|
+
reportMetaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
|
|
62158
62842
|
} else {
|
|
62159
62843
|
const result = await bulkSetSeoMeta(connection, metaEntries);
|
|
62844
|
+
reportMetaStep(metaWriteOutcome(result.results));
|
|
62160
62845
|
const applied = result.results.filter((r) => r.status === "applied").length;
|
|
62161
62846
|
const manual = result.results.filter((r) => r.status === "manual").length;
|
|
62162
62847
|
const skipped4 = result.results.filter((r) => r.status === "skipped").length;
|
|
@@ -62169,6 +62854,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62169
62854
|
} catch (err) {
|
|
62170
62855
|
const msg = describeError(err);
|
|
62171
62856
|
steps.push({ name: "set-meta", status: "failed", error: msg });
|
|
62857
|
+
reportMetaStep(failedOutcome(err, wordpressFailureReason(err)));
|
|
62172
62858
|
return { projectName: project.name, steps };
|
|
62173
62859
|
}
|
|
62174
62860
|
if (skipSchema || !profile) {
|
|
@@ -62178,11 +62864,18 @@ async function wordpressRoutes(app, opts) {
|
|
|
62178
62864
|
summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
|
|
62179
62865
|
});
|
|
62180
62866
|
} else {
|
|
62867
|
+
const schemaStartedAt = Date.now();
|
|
62868
|
+
const reportSchemaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, {
|
|
62869
|
+
...outcome2,
|
|
62870
|
+
durationBucket: bucketDuration(Date.now() - schemaStartedAt)
|
|
62871
|
+
});
|
|
62181
62872
|
try {
|
|
62182
62873
|
if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
|
|
62183
62874
|
steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
|
|
62875
|
+
reportSchemaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.VALIDATION });
|
|
62184
62876
|
} else {
|
|
62185
62877
|
const result = await deploySchemaFromProfile(connection, profile);
|
|
62878
|
+
reportSchemaStep(schemaDeployOutcome(result.results));
|
|
62186
62879
|
const deployed = result.results.filter((r) => r.status === "deployed").length;
|
|
62187
62880
|
const stripped = result.results.filter((r) => r.status === "stripped").length;
|
|
62188
62881
|
const skipped4 = result.results.filter((r) => r.status === "skipped").length;
|
|
@@ -62195,6 +62888,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62195
62888
|
} catch (err) {
|
|
62196
62889
|
const msg = describeError(err);
|
|
62197
62890
|
steps.push({ name: "schema-deploy", status: "failed", error: msg });
|
|
62891
|
+
reportSchemaStep(failedOutcome(err, wordpressFailureReason(err)));
|
|
62198
62892
|
return { projectName: project.name, steps };
|
|
62199
62893
|
}
|
|
62200
62894
|
}
|
|
@@ -62262,7 +62956,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62262
62956
|
entityId: project.name
|
|
62263
62957
|
});
|
|
62264
62958
|
return { projectName: project.name, steps };
|
|
62265
|
-
});
|
|
62959
|
+
}, attempt);
|
|
62266
62960
|
});
|
|
62267
62961
|
}
|
|
62268
62962
|
|
|
@@ -62854,13 +63548,14 @@ async function backlinksRoutes(app, opts) {
|
|
|
62854
63548
|
}
|
|
62855
63549
|
return reply.send(opts.getBacklinksStatus());
|
|
62856
63550
|
});
|
|
62857
|
-
app.post("/backlinks/install", async (_request, reply) => {
|
|
63551
|
+
app.post("/backlinks/install", async (_request, reply) => withFeatureOutcome(app, { feature: "backlinks", operation: "install" }, async (settle) => {
|
|
62858
63552
|
if (!opts.onInstallBacklinks) {
|
|
62859
63553
|
throw missingDependency(BACKLINKS_UNSUPPORTED_MESSAGE);
|
|
62860
63554
|
}
|
|
62861
63555
|
const result = await opts.onInstallBacklinks();
|
|
63556
|
+
if (result.alreadyPresent) settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.ALREADY_CONNECTED });
|
|
62862
63557
|
return reply.status(200).send(result);
|
|
62863
|
-
});
|
|
63558
|
+
}, (err) => err instanceof AppError && err.code === "MISSING_DEPENDENCY" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
|
|
62864
63559
|
app.post("/backlinks/syncs", async (request, reply) => {
|
|
62865
63560
|
let release = request.body?.release;
|
|
62866
63561
|
if (!release) {
|
|
@@ -79023,6 +79718,7 @@ async function deliverViaDirectPush(env, batch) {
|
|
|
79023
79718
|
method: 'POST',
|
|
79024
79719
|
headers: {
|
|
79025
79720
|
'content-type': 'application/json',
|
|
79721
|
+
'user-agent': 'canonry-cloudflare-worker/' + batch.workerVersion,
|
|
79026
79722
|
'Authorization': 'Bearer ' + bearerToken,
|
|
79027
79723
|
'X-Canonry-Timestamp': timestamp,
|
|
79028
79724
|
'X-Canonry-Signature': signature,
|
|
@@ -80481,6 +81177,12 @@ async function runBackfillTask(options) {
|
|
|
80481
81177
|
pullForBackfill,
|
|
80482
81178
|
pullErrorPrefix
|
|
80483
81179
|
} = options;
|
|
81180
|
+
const backfillStartedAt = Date.now();
|
|
81181
|
+
const target = { feature: "server_traffic", operation: "backfill", attribution: currentOutcomeAttribution() };
|
|
81182
|
+
const reportBackfill = (outcome2) => reportFeatureOutcome(app, target, {
|
|
81183
|
+
...outcome2,
|
|
81184
|
+
durationBucket: bucketDuration(Date.now() - backfillStartedAt)
|
|
81185
|
+
});
|
|
80484
81186
|
const markFailed = (msg, preserveSourceState = false) => {
|
|
80485
81187
|
const failedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
80486
81188
|
try {
|
|
@@ -80496,6 +81198,7 @@ async function runBackfillTask(options) {
|
|
|
80496
81198
|
};
|
|
80497
81199
|
if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
|
|
80498
81200
|
markFailed("Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span.");
|
|
81201
|
+
reportBackfill({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
80499
81202
|
return;
|
|
80500
81203
|
}
|
|
80501
81204
|
let allEvents;
|
|
@@ -80506,12 +81209,14 @@ async function runBackfillTask(options) {
|
|
|
80506
81209
|
`${pullErrorPrefix}: ${describeError(e)}`,
|
|
80507
81210
|
e instanceof VercelRetentionClampError
|
|
80508
81211
|
);
|
|
81212
|
+
reportBackfill(failedOutcome(e, e instanceof VercelRetentionClampError ? OutcomeReasonCodes.NO_DATA : void 0));
|
|
80509
81213
|
return;
|
|
80510
81214
|
}
|
|
80511
81215
|
if (allEvents.length === 0) {
|
|
80512
81216
|
const finishedAt2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
81217
|
+
let emptyOutcome;
|
|
80513
81218
|
try {
|
|
80514
|
-
app.db.transaction((tx) => {
|
|
81219
|
+
const completed = app.db.transaction((tx) => {
|
|
80515
81220
|
const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
80516
81221
|
const stillAuthoritative = latestSource && isAuthoritativeTrafficSource(tx, latestSource) && isSameTrafficSourceGeneration(latestSource, sourceRow2);
|
|
80517
81222
|
if (stillAuthoritative) {
|
|
@@ -80522,9 +81227,13 @@ async function runBackfillTask(options) {
|
|
|
80522
81227
|
error: "Traffic source was deactivated or reconfigured during backfill",
|
|
80523
81228
|
finishedAt: finishedAt2
|
|
80524
81229
|
}).where(eq69(runs.id, runId)).run();
|
|
81230
|
+
return Boolean(stillAuthoritative);
|
|
80525
81231
|
});
|
|
80526
|
-
|
|
81232
|
+
emptyOutcome = completed ? { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA } : { status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
|
|
81233
|
+
} catch (e) {
|
|
81234
|
+
emptyOutcome = failedOutcome(e);
|
|
80527
81235
|
}
|
|
81236
|
+
reportBackfill(emptyOutcome);
|
|
80528
81237
|
return;
|
|
80529
81238
|
}
|
|
80530
81239
|
const report = buildTrafficProbeReport(allEvents, { sampleLimit: BACKFILL_SAMPLE_LIMIT });
|
|
@@ -80784,11 +81493,30 @@ async function runBackfillTask(options) {
|
|
|
80784
81493
|
tx.update(runs).set({ status: RunStatuses.completed, finishedAt }).where(eq69(runs.id, runId)).run();
|
|
80785
81494
|
return "committed";
|
|
80786
81495
|
});
|
|
81496
|
+
reportBackfill(commitOutcome === "committed" ? {
|
|
81497
|
+
status: OutcomeStatuses.succeeded,
|
|
81498
|
+
counts: {
|
|
81499
|
+
events: report.totals.normalizedEvents,
|
|
81500
|
+
crawlerHits: report.totals.crawlerHits,
|
|
81501
|
+
aiReferralHits: report.totals.aiReferralHits,
|
|
81502
|
+
aiUserFetchHits: report.totals.aiUserFetchHits
|
|
81503
|
+
}
|
|
81504
|
+
} : {
|
|
81505
|
+
status: OutcomeStatuses.failed,
|
|
81506
|
+
reasonCode: commitOutcome === "source-inactive" ? OutcomeReasonCodes.NOT_CONNECTED : OutcomeReasonCodes.UNSUPPORTED
|
|
81507
|
+
});
|
|
80787
81508
|
if (commitOutcome === "source-inactive" || commitOutcome === "wordpress-backfill-unsupported") return;
|
|
80788
81509
|
} catch (e) {
|
|
80789
81510
|
markFailed(`Backfill rollup write failed: ${describeError(e)}`);
|
|
81511
|
+
reportBackfill(failedOutcome(e));
|
|
80790
81512
|
}
|
|
80791
81513
|
}
|
|
81514
|
+
var TRAFFIC_SOURCE_INTEGRATIONS = {
|
|
81515
|
+
[TrafficSourceTypes.cloudflare]: "traffic_cloudflare",
|
|
81516
|
+
[TrafficSourceTypes["cloud-run"]]: "traffic_cloud_run",
|
|
81517
|
+
[TrafficSourceTypes.vercel]: "traffic_vercel",
|
|
81518
|
+
[TrafficSourceTypes.wordpress]: "traffic_wordpress"
|
|
81519
|
+
};
|
|
80792
81520
|
async function trafficRoutes(app, opts) {
|
|
80793
81521
|
const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
|
|
80794
81522
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
@@ -80820,9 +81548,10 @@ async function trafficRoutes(app, opts) {
|
|
|
80820
81548
|
app.addHook("onClose", async () => {
|
|
80821
81549
|
if (rawSampleRetentionTimer) clearInterval(rawSampleRetentionTimer);
|
|
80822
81550
|
});
|
|
80823
|
-
async function assertWordpressTargetAllowed(baseUrl) {
|
|
81551
|
+
async function assertWordpressTargetAllowed(baseUrl, attempt) {
|
|
80824
81552
|
const check2 = await resolveWebhookTarget(baseUrl, { allowLoopback });
|
|
80825
81553
|
if (!check2.ok) {
|
|
81554
|
+
attempt?.failed(void 0, webhookTargetRefusalReason(check2));
|
|
80826
81555
|
throw validationError(`WordPress baseUrl rejected: ${check2.message}`);
|
|
80827
81556
|
}
|
|
80828
81557
|
}
|
|
@@ -80877,7 +81606,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80877
81606
|
}
|
|
80878
81607
|
throw validationError(`Traffic source type "${source.sourceType}" cannot be activated`);
|
|
80879
81608
|
}
|
|
80880
|
-
app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
|
|
81609
|
+
app.post("/projects/:name/traffic/connect/cloud-run", connectionRoute(app, () => ({ integration: "traffic_cloud_run", action: "connect" }), async (request, _reply, attempt) => {
|
|
80881
81610
|
const project = resolveProject(app.db, request.params.name);
|
|
80882
81611
|
const body = request.body ?? {};
|
|
80883
81612
|
const { gcpProjectId, serviceName, location, displayName, keyJson } = body;
|
|
@@ -80903,6 +81632,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80903
81632
|
}
|
|
80904
81633
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
80905
81634
|
const existing = opts.cloudRunCredentialStore.getConnection(project.name);
|
|
81635
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
80906
81636
|
opts.cloudRunCredentialStore.upsertConnection({
|
|
80907
81637
|
projectName: project.name,
|
|
80908
81638
|
gcpProjectId,
|
|
@@ -80966,8 +81696,8 @@ async function trafficRoutes(app, opts) {
|
|
|
80966
81696
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
80967
81697
|
}
|
|
80968
81698
|
return rowToDto(sourceRow2);
|
|
80969
|
-
});
|
|
80970
|
-
app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
|
|
81699
|
+
}));
|
|
81700
|
+
app.post("/projects/:name/traffic/connect/wordpress", connectionRoute(app, () => ({ integration: "traffic_wordpress", action: "connect" }), async (request, _reply, attempt) => {
|
|
80971
81701
|
const project = resolveProject(app.db, request.params.name);
|
|
80972
81702
|
if (!opts.wordpressTrafficCredentialStore) {
|
|
80973
81703
|
throw validationError("WordPress traffic credential storage is not configured for this deployment");
|
|
@@ -80978,7 +81708,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80978
81708
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
80979
81709
|
}
|
|
80980
81710
|
const { baseUrl, username, applicationPassword, displayName } = parsed.data;
|
|
80981
|
-
await assertWordpressTargetAllowed(baseUrl);
|
|
81711
|
+
await assertWordpressTargetAllowed(baseUrl, attempt);
|
|
80982
81712
|
try {
|
|
80983
81713
|
await pullWordpressEvents({
|
|
80984
81714
|
baseUrl,
|
|
@@ -80989,6 +81719,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80989
81719
|
fetchImpl: wordpressTrafficFetch
|
|
80990
81720
|
});
|
|
80991
81721
|
} catch (e) {
|
|
81722
|
+
attempt.failed(e, e instanceof EgressRefusedError ? "BLOCKED_UNSAFE_URL" : e instanceof EgressFailedError ? "NETWORK" : void 0);
|
|
80992
81723
|
if (e instanceof WordpressTrafficApiError) {
|
|
80993
81724
|
throw providerError(
|
|
80994
81725
|
`WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
@@ -81000,6 +81731,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81000
81731
|
}
|
|
81001
81732
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81002
81733
|
const existing = credentialStore.getConnection(project.name);
|
|
81734
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
81003
81735
|
credentialStore.upsertConnection({
|
|
81004
81736
|
projectName: project.name,
|
|
81005
81737
|
baseUrl,
|
|
@@ -81063,8 +81795,8 @@ async function trafficRoutes(app, opts) {
|
|
|
81063
81795
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81064
81796
|
}
|
|
81065
81797
|
return rowToDto(sourceRow2);
|
|
81066
|
-
});
|
|
81067
|
-
app.post("/projects/:name/traffic/connect/vercel", async (request) => {
|
|
81798
|
+
}));
|
|
81799
|
+
app.post("/projects/:name/traffic/connect/vercel", connectionRoute(app, () => ({ integration: "traffic_vercel", action: "connect" }), async (request, _reply, attempt) => {
|
|
81068
81800
|
const project = resolveProject(app.db, request.params.name);
|
|
81069
81801
|
if (!opts.vercelTrafficCredentialStore) {
|
|
81070
81802
|
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
@@ -81088,6 +81820,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81088
81820
|
maxPages: 1
|
|
81089
81821
|
});
|
|
81090
81822
|
} catch (e) {
|
|
81823
|
+
attempt.failed(e);
|
|
81091
81824
|
if (e instanceof VercelLogsApiError) {
|
|
81092
81825
|
throw providerError(
|
|
81093
81826
|
`Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
@@ -81098,6 +81831,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81098
81831
|
}
|
|
81099
81832
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81100
81833
|
const existing = credentialStore.getConnection(project.name);
|
|
81834
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
81101
81835
|
credentialStore.upsertConnection({
|
|
81102
81836
|
projectName: project.name,
|
|
81103
81837
|
projectId,
|
|
@@ -81175,8 +81909,8 @@ async function trafficRoutes(app, opts) {
|
|
|
81175
81909
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81176
81910
|
}
|
|
81177
81911
|
return rowToDto(sourceRow2);
|
|
81178
|
-
});
|
|
81179
|
-
app.post("/projects/:name/traffic/connect/cloudflare", async (request) => {
|
|
81912
|
+
}));
|
|
81913
|
+
app.post("/projects/:name/traffic/connect/cloudflare", connectionRoute(app, () => ({ integration: "traffic_cloudflare", action: "connect" }), async (request, _reply, attempt) => {
|
|
81180
81914
|
const project = resolveProject(app.db, request.params.name);
|
|
81181
81915
|
if (!opts.cloudflareTrafficCredentialStore) {
|
|
81182
81916
|
throw validationError("Cloudflare traffic credential storage is not configured for this deployment");
|
|
@@ -81192,6 +81926,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81192
81926
|
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
81193
81927
|
const cloudflareSources = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).all().filter((row) => row.sourceType === TrafficSourceTypes.cloudflare && row.status !== TrafficSourceStatuses.archived);
|
|
81194
81928
|
const sameModeSource = cloudflareSources.find((row) => parseQueuePullCloudflareSourceConfig(row.configJson));
|
|
81929
|
+
if (sameModeSource) attempt.update({ action: "reauth" });
|
|
81195
81930
|
const sourceId2 = sameModeSource?.id ?? crypto48.randomUUID();
|
|
81196
81931
|
const previousCredential2 = credentialStore.getConnectionBySourceId(sourceId2);
|
|
81197
81932
|
const workerVersion2 = CURRENT_CLOUDFLARE_WORKER_VERSION;
|
|
@@ -81327,6 +82062,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81327
82062
|
const workerRouteHost = resolveCloudflareWorkerRouteHost(project.canonicalDomain);
|
|
81328
82063
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81329
82064
|
const activeSource = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.cloudflare && row.status !== TrafficSourceStatuses.archived && parseDirectPushCloudflareSourceConfig(row.configJson) !== null);
|
|
82065
|
+
if (activeSource) attempt.update({ action: "reauth" });
|
|
81330
82066
|
const sourceId = activeSource?.id ?? crypto48.randomUUID();
|
|
81331
82067
|
const parsedActiveConfig = activeSource ? parseDirectPushCloudflareSourceConfig(activeSource.configJson) : null;
|
|
81332
82068
|
if (activeSource && !parsedActiveConfig) {
|
|
@@ -81486,8 +82222,12 @@ async function trafficRoutes(app, opts) {
|
|
|
81486
82222
|
workerVersion,
|
|
81487
82223
|
instructions
|
|
81488
82224
|
};
|
|
81489
|
-
});
|
|
81490
|
-
app.post("/projects/:name/traffic/sources/:id/activate",
|
|
82225
|
+
}));
|
|
82226
|
+
app.post("/projects/:name/traffic/sources/:id/activate", connectionRoute(app, (request) => {
|
|
82227
|
+
const source = app.db.select({ sourceType: trafficSources.sourceType }).from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
|
|
82228
|
+
const integration = source ? TRAFFIC_SOURCE_INTEGRATIONS[source.sourceType] : void 0;
|
|
82229
|
+
return integration ? { integration, action: "select" } : void 0;
|
|
82230
|
+
}, async (request) => {
|
|
81491
82231
|
const project = resolveProject(app.db, request.params.name);
|
|
81492
82232
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81493
82233
|
const result = app.db.transaction((tx) => {
|
|
@@ -81538,7 +82278,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81538
82278
|
opts.onScheduleUpdated?.(result.scheduleAction, project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81539
82279
|
}
|
|
81540
82280
|
return rowToDto(result.source);
|
|
81541
|
-
});
|
|
82281
|
+
}));
|
|
81542
82282
|
app.post("/projects/:name/traffic/cloudflare/ingest", {
|
|
81543
82283
|
bodyLimit: CLOUDFLARE_INGEST_BODY_LIMIT,
|
|
81544
82284
|
preHandler: async (request, reply) => {
|
|
@@ -81567,6 +82307,22 @@ async function trafficRoutes(app, opts) {
|
|
|
81567
82307
|
}
|
|
81568
82308
|
}
|
|
81569
82309
|
}, async (request, reply) => {
|
|
82310
|
+
const ingestStartedMs = Date.now();
|
|
82311
|
+
const reportIngest = (outcome2) => {
|
|
82312
|
+
try {
|
|
82313
|
+
opts.onTrafficIngested?.("failure" in outcome2 ? {
|
|
82314
|
+
sourceType: TrafficSourceTypes.cloudflare,
|
|
82315
|
+
status: "failed",
|
|
82316
|
+
events: 0,
|
|
82317
|
+
crawlerHits: 0,
|
|
82318
|
+
aiReferralHits: 0,
|
|
82319
|
+
aiUserFetchHits: 0,
|
|
82320
|
+
durationMs: Date.now() - ingestStartedMs,
|
|
82321
|
+
...routeOutcomeFailure(outcome2.failure, outcome2.reasonCode)
|
|
82322
|
+
} : { sourceType: TrafficSourceTypes.cloudflare, status: "succeeded", ...outcome2, durationMs: Date.now() - ingestStartedMs });
|
|
82323
|
+
} catch {
|
|
82324
|
+
}
|
|
82325
|
+
};
|
|
81570
82326
|
const authenticated = authenticateCloudflareIngest(
|
|
81571
82327
|
request,
|
|
81572
82328
|
opts.cloudflareTrafficCredentialStore
|
|
@@ -81584,6 +82340,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81584
82340
|
if (sourceRow2.projectId !== project.id) throw authRequired();
|
|
81585
82341
|
const parsed = cloudflareWorkerIngestRequestSchema.safeParse(request.body);
|
|
81586
82342
|
if (!parsed.success) {
|
|
82343
|
+
reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
|
|
81587
82344
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
81588
82345
|
}
|
|
81589
82346
|
const { workerVersion, events } = parsed.data;
|
|
@@ -81592,6 +82349,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81592
82349
|
(event) => normalizeCloudflareEventHost(event.host) !== canonicalHost
|
|
81593
82350
|
);
|
|
81594
82351
|
if (wrongHostIndex !== -1) {
|
|
82352
|
+
reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
|
|
81595
82353
|
throw validationError(
|
|
81596
82354
|
`Cloudflare event at index ${wrongHostIndex} does not belong to project domain "${project.canonicalDomain}"`
|
|
81597
82355
|
);
|
|
@@ -81604,25 +82362,37 @@ async function trafficRoutes(app, opts) {
|
|
|
81604
82362
|
else normalizationDrops += 1;
|
|
81605
82363
|
}
|
|
81606
82364
|
const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
81607
|
-
|
|
81608
|
-
|
|
81609
|
-
|
|
81610
|
-
|
|
81611
|
-
|
|
81612
|
-
|
|
81613
|
-
|
|
81614
|
-
|
|
81615
|
-
|
|
81616
|
-
|
|
81617
|
-
|
|
82365
|
+
let writeResult;
|
|
82366
|
+
try {
|
|
82367
|
+
writeResult = writeTrafficEventBatch({
|
|
82368
|
+
db: app.db,
|
|
82369
|
+
projectId: project.id,
|
|
82370
|
+
sourceId,
|
|
82371
|
+
events: normalized,
|
|
82372
|
+
receivedAt,
|
|
82373
|
+
receiptTtlMs: DIRECT_PUSH_RECEIPT_TTL_MS,
|
|
82374
|
+
sampleLimit,
|
|
82375
|
+
validateSource: (latestRow) => {
|
|
82376
|
+
if (!latestRow || latestRow.projectId !== project.id || latestRow.sourceType !== TrafficSourceTypes.cloudflare || latestRow.status !== TrafficSourceStatuses.connected || !parseDirectPushCloudflareSourceConfig(latestRow.configJson) || !timingSafeEqualHex(bearerHash, latestRow.ingestTokenHash)) {
|
|
82377
|
+
throw authRequired();
|
|
82378
|
+
}
|
|
82379
|
+
},
|
|
82380
|
+
sourceUpdate: {
|
|
82381
|
+
lastWorkerVersion: workerVersion,
|
|
82382
|
+
lastSyncedAt: receivedAt,
|
|
82383
|
+
lastError: null,
|
|
82384
|
+
updatedAt: receivedAt
|
|
81618
82385
|
}
|
|
81619
|
-
}
|
|
81620
|
-
|
|
81621
|
-
|
|
81622
|
-
|
|
81623
|
-
|
|
81624
|
-
|
|
81625
|
-
|
|
82386
|
+
});
|
|
82387
|
+
} catch (err) {
|
|
82388
|
+
reportIngest({ failure: err });
|
|
82389
|
+
throw err;
|
|
82390
|
+
}
|
|
82391
|
+
reportIngest({
|
|
82392
|
+
events: writeResult.acceptedEvents,
|
|
82393
|
+
crawlerHits: writeResult.crawlerHits,
|
|
82394
|
+
aiReferralHits: writeResult.aiReferralHits,
|
|
82395
|
+
aiUserFetchHits: writeResult.aiUserFetchHits
|
|
81626
82396
|
});
|
|
81627
82397
|
return reply.status(200).send({
|
|
81628
82398
|
acceptedEvents: writeResult.acceptedEvents,
|
|
@@ -81860,6 +82630,22 @@ async function trafficRoutes(app, opts) {
|
|
|
81860
82630
|
} catch (error) {
|
|
81861
82631
|
const failedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
81862
82632
|
const safeError = error instanceof CloudflareQueueApiError ? error.message : "Cloudflare Queue sync failed; retry the active source.";
|
|
82633
|
+
try {
|
|
82634
|
+
opts.onTrafficSynced?.({
|
|
82635
|
+
status: "failed",
|
|
82636
|
+
sourceType: sourceRow2.sourceType,
|
|
82637
|
+
sourceId: sourceRow2.id,
|
|
82638
|
+
pulledEvents: 0,
|
|
82639
|
+
selfTrafficExcluded: 0,
|
|
82640
|
+
crawlerHits: 0,
|
|
82641
|
+
aiUserFetchHits: 0,
|
|
82642
|
+
aiReferralHits: 0,
|
|
82643
|
+
durationMs: Date.now() - startedMs,
|
|
82644
|
+
// A 401/403 is the stored API token; any other Queue API answer is the pull.
|
|
82645
|
+
errorCode: error instanceof CloudflareQueueApiError ? error.status === 401 || error.status === 403 ? "PROVIDER_AUTH" : "PROVIDER_PULL" : "INTERNAL"
|
|
82646
|
+
});
|
|
82647
|
+
} catch {
|
|
82648
|
+
}
|
|
81863
82649
|
app.db.transaction((tx) => {
|
|
81864
82650
|
tx.update(runs).set({ status: RunStatuses.failed, error: safeError, finishedAt: failedAt }).where(eq69(runs.id, runId)).run();
|
|
81865
82651
|
const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
@@ -82573,7 +83359,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82573
83359
|
}
|
|
82574
83360
|
}
|
|
82575
83361
|
});
|
|
82576
|
-
app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => {
|
|
83362
|
+
app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "backfill" }, async (recordOutcome) => {
|
|
82577
83363
|
const project = resolveProject(app.db, request.params.name);
|
|
82578
83364
|
const sourceRow2 = app.db.select().from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
|
|
82579
83365
|
if (!sourceRow2 || sourceRow2.projectId !== project.id) {
|
|
@@ -82714,6 +83500,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82714
83500
|
pullErrorPrefix
|
|
82715
83501
|
}).catch(() => {
|
|
82716
83502
|
});
|
|
83503
|
+
recordOutcome(null);
|
|
82717
83504
|
const response = {
|
|
82718
83505
|
sourceId: sourceRow2.id,
|
|
82719
83506
|
runId,
|
|
@@ -82724,7 +83511,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82724
83511
|
daysApplied: appliedDays
|
|
82725
83512
|
};
|
|
82726
83513
|
return response;
|
|
82727
|
-
});
|
|
83514
|
+
}));
|
|
82728
83515
|
function buildSourceDetail(projectId, row, since) {
|
|
82729
83516
|
const crawlerPathRows = app.db.select({
|
|
82730
83517
|
pathNormalized: crawlerEventsHourly.pathNormalized,
|
|
@@ -82790,7 +83577,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82790
83577
|
} : null
|
|
82791
83578
|
};
|
|
82792
83579
|
}
|
|
82793
|
-
app.post("/projects/:name/traffic/sources/:id/reset", async (request) => {
|
|
83580
|
+
app.post("/projects/:name/traffic/sources/:id/reset", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "reset" }, async () => {
|
|
82794
83581
|
const project = resolveProject(app.db, request.params.name);
|
|
82795
83582
|
const parsed = trafficResetRequestSchema.safeParse(request.body ?? {});
|
|
82796
83583
|
if (!parsed.success) {
|
|
@@ -82843,7 +83630,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82843
83630
|
updatedRow = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
82844
83631
|
});
|
|
82845
83632
|
return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
|
|
82846
|
-
});
|
|
83633
|
+
}));
|
|
82847
83634
|
app.get("/projects/:name/traffic/sources", async (request) => {
|
|
82848
83635
|
const project = resolveProject(app.db, request.params.name);
|
|
82849
83636
|
const rows = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).orderBy(desc35(trafficSources.createdAt)).all();
|
|
@@ -90894,6 +91681,7 @@ function captureSimpleMeasurementDefinition(db, input) {
|
|
|
90894
91681
|
// ../api-routes/src/index.ts
|
|
90895
91682
|
async function apiRoutes(app, opts) {
|
|
90896
91683
|
app.decorate("db", opts.db);
|
|
91684
|
+
app.decorate("emitOutcome", createOutcomeEmitter(opts.onOutcome));
|
|
90897
91685
|
app.setErrorHandler((error, _request, reply) => {
|
|
90898
91686
|
if (error instanceof AppError) {
|
|
90899
91687
|
return reply.status(error.statusCode).send(error.toJSON());
|
|
@@ -91167,6 +91955,7 @@ async function apiRoutes(app, opts) {
|
|
|
91167
91955
|
cloudflareIngestIpRateLimitMax: opts.cloudflareIngestIpRateLimitMax,
|
|
91168
91956
|
defaultSampleLimit: opts.defaultTrafficSampleLimit,
|
|
91169
91957
|
onTrafficSynced: opts.onTrafficSynced,
|
|
91958
|
+
onTrafficIngested: opts.onTrafficIngested,
|
|
91170
91959
|
onScheduleUpdated: opts.onScheduleUpdated,
|
|
91171
91960
|
allowLoopbackWebhooks: opts.allowLoopbackWebhooks
|
|
91172
91961
|
});
|
|
@@ -92497,6 +93286,7 @@ export {
|
|
|
92497
93286
|
bingConnections,
|
|
92498
93287
|
bingUrlInspections,
|
|
92499
93288
|
bingKeywordStats,
|
|
93289
|
+
gaConnections,
|
|
92500
93290
|
gaTrafficSnapshots,
|
|
92501
93291
|
gaDailyTotals,
|
|
92502
93292
|
gaAcquisitionDaily,
|
|
@@ -92574,6 +93364,7 @@ export {
|
|
|
92574
93364
|
createCredentialChecker,
|
|
92575
93365
|
requireInstanceAdministrator,
|
|
92576
93366
|
hashApiKey,
|
|
93367
|
+
currentOutcomeAttribution,
|
|
92577
93368
|
notProbeRun,
|
|
92578
93369
|
writeAuditLog,
|
|
92579
93370
|
auditFromRequest,
|
|
@@ -92587,9 +93378,15 @@ export {
|
|
|
92587
93378
|
SentimentService,
|
|
92588
93379
|
registerOAuthRoutes,
|
|
92589
93380
|
registerOAuthAdminRoutes,
|
|
93381
|
+
credentialFailure,
|
|
92590
93382
|
createRunCompetitorResolver,
|
|
92591
93383
|
competitorIdentityColumns,
|
|
92592
93384
|
readMarketCompetitorNames,
|
|
93385
|
+
toAlertView,
|
|
93386
|
+
resolveDestination,
|
|
93387
|
+
webhookOutcomeTarget,
|
|
93388
|
+
webhookTargetRefusalReason,
|
|
93389
|
+
webhookResponseReason,
|
|
92593
93390
|
latestQueryBasketRevision,
|
|
92594
93391
|
ensureCurrentQueryBasketRevision,
|
|
92595
93392
|
queueRunIfProjectIdle,
|
|
@@ -92609,19 +93406,20 @@ export {
|
|
|
92609
93406
|
deliverWebhook,
|
|
92610
93407
|
redactNotificationUrl,
|
|
92611
93408
|
buildOpenApiDocument,
|
|
92612
|
-
toAlertView,
|
|
92613
|
-
resolveDestination,
|
|
92614
93409
|
getPlaceDetails,
|
|
92615
93410
|
hashPlaceDetails,
|
|
92616
93411
|
getPlaceReviewSignals,
|
|
92617
93412
|
GSC_REPORTING_TIME_ZONE,
|
|
92618
93413
|
GSC_DATA_LAG_DAYS,
|
|
93414
|
+
GoogleAuthError,
|
|
93415
|
+
GoogleApiError,
|
|
92619
93416
|
getAuthUrl,
|
|
92620
93417
|
exchangeCode,
|
|
92621
93418
|
refreshAccessToken,
|
|
92622
93419
|
fetchSearchAnalytics,
|
|
92623
93420
|
inspectUrl,
|
|
92624
93421
|
GBP_DAILY_METRICS,
|
|
93422
|
+
GbpApiError,
|
|
92625
93423
|
listLocations,
|
|
92626
93424
|
formatStorefrontAddress,
|
|
92627
93425
|
buildLocationProfileFields,
|
|
@@ -92636,6 +93434,7 @@ export {
|
|
|
92636
93434
|
hashAttributes,
|
|
92637
93435
|
listReviews,
|
|
92638
93436
|
classifyReviewsError,
|
|
93437
|
+
BingApiError,
|
|
92639
93438
|
getUrlInfo,
|
|
92640
93439
|
getCrawlIssues,
|
|
92641
93440
|
EgressRefusedError,
|