@canonry/canonry 7.23.0 → 7.24.1
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/assets/{AuditHistoryPanel-J8AR3x5F.js → AuditHistoryPanel-DK3zE9dO.js} +1 -1
- package/assets/assets/{BacklinksPage-CRDPutGA.js → BacklinksPage-WYFDcXpX.js} +1 -1
- package/assets/assets/{HistoryPage-C5taVW3t.js → HistoryPage-CSlndAeS.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-txucmLuz.js → MeasurementPropertyPage-CivoZOGQ.js} +1 -1
- package/assets/assets/{ProjectPage-C4FaBoWI.js → ProjectPage-zidnYv65.js} +1 -1
- package/assets/assets/{RunsPage-D-3XKUhh.js → RunsPage-BGMAZ_mA.js} +1 -1
- package/assets/assets/{SettingsPage-Cq_SJRj_.js → SettingsPage-ndSVVJcE.js} +1 -1
- package/assets/assets/{SiteHealthSection-Cn_IZVO1.js → SiteHealthSection-C7-RrsKk.js} +3 -3
- package/assets/assets/{TrafficPage-BMdFZs3X.js → TrafficPage-2ANBE9Lc.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-BmrIOc-2.js → TrafficSourceDetailPage-CLYVB4Uq.js} +1 -1
- package/assets/assets/{extract-error-message-HlQy5L7O.js → extract-error-message-BzPoRYiA.js} +1 -1
- package/assets/assets/index-CjowCLs3.js +86 -0
- package/assets/assets/{react-sigma_core.esm.min-CogAr1i4.js → react-sigma_core.esm.min-BmKqjMGN.js} +1 -1
- package/assets/assets/{v2-overview-adapter-tps7TUxq.js → v2-overview-adapter-C7eI2iBf.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-5IXQAOVK.js → chunk-332IBSJJ.js} +15 -6
- package/dist/{chunk-3OPV5XBY.js → chunk-35IRQYDW.js} +5709 -4588
- package/dist/{chunk-222NAFTY.js → chunk-43FZZFOK.js} +2 -2
- package/dist/{chunk-UF7MH6TU.js → chunk-5ITYNLS3.js} +2 -2
- package/dist/{chunk-MRKV43TV.js → chunk-656KW4RB.js} +566 -206
- package/dist/{chunk-46JZR2IV.js → chunk-H7UNFLYH.js} +1306 -505
- package/dist/{chunk-KWBFKTOA.js → chunk-OAWFZAAJ.js} +3 -3
- package/dist/cli.js +199 -49
- package/dist/{demo-server-32PU7BM6.js → demo-server-NLD6B4WH.js} +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -6
- package/dist/{intelligence-service-256DQBVR.js → intelligence-service-JFHM2EAY.js} +2 -2
- package/dist/mcp.js +4 -4
- package/package.json +11 -11
- 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-656KW4RB.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")) {
|
|
@@ -46846,7 +47459,9 @@ async function telemetryRoutes(app, opts) {
|
|
|
46846
47459
|
opts.recordOnboardingEvent?.(normalizeOnboardingEventForCollection(parsed.data));
|
|
46847
47460
|
return reply.status(202).send({ accepted: Boolean(opts.recordOnboardingEvent) });
|
|
46848
47461
|
});
|
|
46849
|
-
app.post("/telemetry/ui",
|
|
47462
|
+
app.post("/telemetry/ui", {
|
|
47463
|
+
config: { readSemantic: true }
|
|
47464
|
+
}, async (request, reply) => {
|
|
46850
47465
|
const parsed = uiTelemetryEventSchema.safeParse(request.body);
|
|
46851
47466
|
if (!parsed.success) {
|
|
46852
47467
|
throw validationError("Invalid UI telemetry event", {
|
|
@@ -47132,311 +47747,32 @@ function formatSchedule(row) {
|
|
|
47132
47747
|
// ../api-routes/src/notifications.ts
|
|
47133
47748
|
import crypto30 from "crypto";
|
|
47134
47749
|
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.");
|
|
47750
|
+
var AGENT_WEBHOOK_SOURCE = "agent";
|
|
47751
|
+
function webhookConnection(action, url, source) {
|
|
47190
47752
|
return {
|
|
47191
|
-
|
|
47192
|
-
|
|
47193
|
-
|
|
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) {
|
|
47348
|
-
return {
|
|
47349
|
-
username: "Canonry",
|
|
47350
|
-
embeds: [{
|
|
47351
|
-
title: `${SEVERITY_ICONS[view.severity]} ${view.title}`,
|
|
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
|
-
}]
|
|
47753
|
+
integration: source === AGENT_WEBHOOK_SOURCE ? "agent_webhook" : "webhook",
|
|
47754
|
+
action,
|
|
47755
|
+
...typeof url === "string" && url ? { target: webhookOutcomeTarget(url) } : {}
|
|
47392
47756
|
};
|
|
47393
47757
|
}
|
|
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
47758
|
var VALID_EVENTS = notificationEventSchema.options;
|
|
47429
47759
|
async function notificationRoutes(app, opts = {}) {
|
|
47430
47760
|
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
47431
47761
|
app.get("/notifications/events", async (_request, reply) => {
|
|
47432
47762
|
return reply.send(VALID_EVENTS);
|
|
47433
47763
|
});
|
|
47434
|
-
app.post("/projects/:name/notifications",
|
|
47764
|
+
app.post("/projects/:name/notifications", connectionRoute(app, (request) => {
|
|
47765
|
+
const body = request.body;
|
|
47766
|
+
return webhookConnection("connect", body?.url, body?.source);
|
|
47767
|
+
}, async (request, reply, attempt) => {
|
|
47435
47768
|
const project = resolveProject(app.db, request.params.name);
|
|
47436
47769
|
const { channel, url, events, source } = request.body ?? {};
|
|
47437
47770
|
if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
|
|
47438
47771
|
const urlCheck = await resolveWebhookTarget(url ?? "", { allowLoopback });
|
|
47439
|
-
if (!urlCheck.ok)
|
|
47772
|
+
if (!urlCheck.ok) {
|
|
47773
|
+
attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
|
|
47774
|
+
throw validationError(urlCheck.message);
|
|
47775
|
+
}
|
|
47440
47776
|
if (!events?.length) throw validationError('"events" must be a non-empty array');
|
|
47441
47777
|
const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
|
|
47442
47778
|
if (invalid.length) {
|
|
@@ -47467,13 +47803,17 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47467
47803
|
...formatNotification(app.db.select().from(notifications).where(eq56(notifications.id, id)).get()),
|
|
47468
47804
|
webhookSecret
|
|
47469
47805
|
});
|
|
47470
|
-
});
|
|
47806
|
+
}));
|
|
47471
47807
|
app.get("/projects/:name/notifications", async (request, reply) => {
|
|
47472
47808
|
const project = resolveProject(app.db, request.params.name);
|
|
47473
47809
|
const rows = app.db.select().from(notifications).where(eq56(notifications.projectId, project.id)).all();
|
|
47474
47810
|
return reply.send(rows.map(formatNotification));
|
|
47475
47811
|
});
|
|
47476
|
-
app.delete("/projects/:name/notifications/:id",
|
|
47812
|
+
app.delete("/projects/:name/notifications/:id", connectionRoute(app, (request) => {
|
|
47813
|
+
const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47814
|
+
const config = stored?.config;
|
|
47815
|
+
return config ? webhookConnection("disconnect", config.url, config.source) : void 0;
|
|
47816
|
+
}, async (request, reply) => {
|
|
47477
47817
|
const project = resolveProject(app.db, request.params.name);
|
|
47478
47818
|
const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47479
47819
|
if (!notification || notification.projectId !== project.id) {
|
|
@@ -47488,8 +47828,12 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47488
47828
|
entityId: notification.id
|
|
47489
47829
|
});
|
|
47490
47830
|
return reply.status(204).send();
|
|
47491
|
-
});
|
|
47492
|
-
app.post("/projects/:name/notifications/:id/test",
|
|
47831
|
+
}));
|
|
47832
|
+
app.post("/projects/:name/notifications/:id/test", connectionRoute(app, (request) => {
|
|
47833
|
+
const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47834
|
+
const config = stored?.config;
|
|
47835
|
+
return config ? webhookConnection("test", config.url, config.source) : void 0;
|
|
47836
|
+
}, async (request, reply, attempt) => {
|
|
47493
47837
|
const project = resolveProject(app.db, request.params.name);
|
|
47494
47838
|
const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
|
|
47495
47839
|
if (!notification || notification.projectId !== project.id) {
|
|
@@ -47497,7 +47841,10 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47497
47841
|
}
|
|
47498
47842
|
const config = notification.config;
|
|
47499
47843
|
const urlCheck = await resolveWebhookTarget(config.url, { allowLoopback });
|
|
47500
|
-
if (!urlCheck.ok)
|
|
47844
|
+
if (!urlCheck.ok) {
|
|
47845
|
+
attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
|
|
47846
|
+
throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
|
|
47847
|
+
}
|
|
47501
47848
|
const payload = {
|
|
47502
47849
|
source: "canonry",
|
|
47503
47850
|
event: "run.completed",
|
|
@@ -47516,8 +47863,11 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47516
47863
|
const signingSecret = destination.signed ? notification.webhookSecret ?? null : null;
|
|
47517
47864
|
const targetLabel = redactNotificationUrl(config.url).urlDisplay;
|
|
47518
47865
|
request.log.info(`[Notification test] POST ${targetLabel} (${destination.destination})`);
|
|
47519
|
-
const
|
|
47866
|
+
const delivery = await deliverWebhook(urlCheck.target, body, signingSecret);
|
|
47867
|
+
const { status, error } = delivery;
|
|
47520
47868
|
request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
|
|
47869
|
+
const failure = webhookResponseReason(delivery);
|
|
47870
|
+
if (failure) attempt.failed(void 0, failure);
|
|
47521
47871
|
writeAuditLog(app.db, {
|
|
47522
47872
|
projectId: project.id,
|
|
47523
47873
|
actor: "api",
|
|
@@ -47528,7 +47878,7 @@ async function notificationRoutes(app, opts = {}) {
|
|
|
47528
47878
|
});
|
|
47529
47879
|
if (error) throw deliveryFailed(error);
|
|
47530
47880
|
return reply.send({ status, ok: status >= 200 && status < 300 });
|
|
47531
|
-
});
|
|
47881
|
+
}));
|
|
47532
47882
|
}
|
|
47533
47883
|
function formatNotification(row) {
|
|
47534
47884
|
const config = row.config;
|
|
@@ -49769,6 +50119,10 @@ function classifyReviewsError(err) {
|
|
|
49769
50119
|
}
|
|
49770
50120
|
|
|
49771
50121
|
// ../api-routes/src/google.ts
|
|
50122
|
+
var GOOGLE_INTEGRATIONS = { gsc: "gsc", ga4: "ga4", gbp: "gbp" };
|
|
50123
|
+
function googleIntegration(type) {
|
|
50124
|
+
return typeof type === "string" && Object.hasOwn(GOOGLE_INTEGRATIONS, type) ? GOOGLE_INTEGRATIONS[type] : void 0;
|
|
50125
|
+
}
|
|
49772
50126
|
function resolveReportedWindow(resolved, startDate, endDate) {
|
|
49773
50127
|
const start = startDate ?? resolved.startDate;
|
|
49774
50128
|
const end = endDate ?? resolved.endDate;
|
|
@@ -49902,6 +50256,19 @@ function googleAuthErrorStatus(err) {
|
|
|
49902
50256
|
const match = err.message.match(/failed \((\d{3})\)/);
|
|
49903
50257
|
return match ? Number(match[1]) : null;
|
|
49904
50258
|
}
|
|
50259
|
+
function gscOutcomeFailure(err) {
|
|
50260
|
+
const classified = classifyOutcomeError(err);
|
|
50261
|
+
if (err instanceof AppError && (err.code === "NOT_FOUND" || err.code === "VALIDATION_ERROR")) {
|
|
50262
|
+
return { ...classified, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
|
|
50263
|
+
}
|
|
50264
|
+
if (err instanceof GoogleAuthError) {
|
|
50265
|
+
const status = googleAuthErrorStatus(err);
|
|
50266
|
+
const reasonCode = status === 429 ? OutcomeReasonCodes.RATE_LIMITED : status !== null && status >= 500 ? OutcomeReasonCodes.HTTP_5XX : OutcomeReasonCodes.INVALID_CREDENTIALS;
|
|
50267
|
+
return { ...classified, reasonCode };
|
|
50268
|
+
}
|
|
50269
|
+
if (err instanceof GoogleApiError && err.status === 404) return { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND };
|
|
50270
|
+
return classified;
|
|
50271
|
+
}
|
|
49905
50272
|
function gscErrorToAppError(err, context) {
|
|
49906
50273
|
if (err instanceof AppError) return err;
|
|
49907
50274
|
if (err instanceof GoogleApiError) {
|
|
@@ -49985,7 +50352,10 @@ async function googleRoutes(app, opts) {
|
|
|
49985
50352
|
updatedAt: connection.updatedAt
|
|
49986
50353
|
}));
|
|
49987
50354
|
});
|
|
49988
|
-
app.post("/projects/:name/google/connect",
|
|
50355
|
+
app.post("/projects/:name/google/connect", connectionRoute(app, (request) => {
|
|
50356
|
+
const integration = googleIntegration(request.body?.type);
|
|
50357
|
+
return integration ? { integration, action: "connect" } : void 0;
|
|
50358
|
+
}, async (request, _reply, attempt) => {
|
|
49989
50359
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
49990
50360
|
if (!googleClientId || !googleClientSecret) {
|
|
49991
50361
|
throw validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
|
|
@@ -49995,6 +50365,7 @@ async function googleRoutes(app, opts) {
|
|
|
49995
50365
|
throw validationError('type must be "gsc", "ga4", or "gbp"');
|
|
49996
50366
|
}
|
|
49997
50367
|
const project = resolveProject(app.db, request.params.name);
|
|
50368
|
+
if (telemetryRead(() => opts.googleConnectionStore?.getConnection(project.canonicalDomain, type), void 0)) attempt.update({ action: "reauth" });
|
|
49998
50369
|
let redirectUri;
|
|
49999
50370
|
if (publicUrl) {
|
|
50000
50371
|
redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
|
|
@@ -50013,22 +50384,40 @@ async function googleRoutes(app, opts) {
|
|
|
50013
50384
|
domain: project.canonicalDomain,
|
|
50014
50385
|
type,
|
|
50015
50386
|
propertyId,
|
|
50016
|
-
redirectUri
|
|
50387
|
+
redirectUri,
|
|
50388
|
+
// The browser finishes the flow, so the callback reports the surface that started it.
|
|
50389
|
+
...oauthStartAttribution()
|
|
50017
50390
|
},
|
|
50018
50391
|
stateSecret
|
|
50019
50392
|
);
|
|
50020
50393
|
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
50394
|
+
attempt.started();
|
|
50021
50395
|
return { authUrl, redirectUri };
|
|
50022
|
-
});
|
|
50396
|
+
}));
|
|
50023
50397
|
async function handleOAuthCallback(request, reply) {
|
|
50398
|
+
const verifiedState = request.query.state ? verifySignedGoogleOAuthState(request.query.state, stateSecret) : null;
|
|
50399
|
+
const integration = googleIntegration(verifiedState?.type ?? unverifiedOAuthStateField(request.query.state, "type"));
|
|
50400
|
+
const replacesConnection = integration && typeof verifiedState?.domain === "string" && telemetryRead(() => opts.googleConnectionStore?.getConnection(verifiedState.domain, integration), void 0);
|
|
50401
|
+
const attempt = startConnectionAttempt(app, integration ? { integration, action: replacesConnection ? "reauth" : "connect", ...oauthStateAttribution(verifiedState) } : void 0);
|
|
50402
|
+
try {
|
|
50403
|
+
return await completeOAuthCallback(request, reply, attempt);
|
|
50404
|
+
} catch (err) {
|
|
50405
|
+
attempt.failed(err);
|
|
50406
|
+
throw err;
|
|
50407
|
+
}
|
|
50408
|
+
}
|
|
50409
|
+
async function completeOAuthCallback(request, reply, attempt) {
|
|
50024
50410
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50025
50411
|
if (!googleClientId || !googleClientSecret) {
|
|
50412
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
50026
50413
|
return reply.status(500).send("Google OAuth not configured");
|
|
50027
50414
|
}
|
|
50028
50415
|
const store = requireConnectionStore();
|
|
50029
50416
|
const escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
50030
50417
|
const { code, state, error } = request.query;
|
|
50031
50418
|
if (error) {
|
|
50419
|
+
if (error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
|
|
50420
|
+
else attempt.failed(void 0, "AUTH_DENIED");
|
|
50032
50421
|
const safeError = escapeHtml3(String(error));
|
|
50033
50422
|
const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
50034
50423
|
<h2 style="color:#ef4444">Redirect URI mismatch</h2>
|
|
@@ -50055,21 +50444,26 @@ async function googleRoutes(app, opts) {
|
|
|
50055
50444
|
return reply.type("text/html").send(errorHtml);
|
|
50056
50445
|
}
|
|
50057
50446
|
if (!code || !state) {
|
|
50447
|
+
attempt.failed(void 0, state ? "VALIDATION" : "OAUTH_STATE_INVALID");
|
|
50058
50448
|
return reply.status(400).send("Missing code or state parameter");
|
|
50059
50449
|
}
|
|
50060
50450
|
const stateData = verifySignedGoogleOAuthState(state, stateSecret);
|
|
50061
50451
|
if (!stateData) {
|
|
50452
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50062
50453
|
return reply.status(400).send("Invalid or tampered state parameter");
|
|
50063
50454
|
}
|
|
50064
50455
|
const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
|
|
50065
50456
|
if (!projectId) {
|
|
50457
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50066
50458
|
return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
|
|
50067
50459
|
}
|
|
50068
50460
|
const project = app.db.select().from(projects).where(eq57(projects.id, projectId)).get();
|
|
50069
50461
|
if (!project) {
|
|
50462
|
+
attempt.failed(void 0, "NOT_FOUND");
|
|
50070
50463
|
return reply.status(400).send("Project no longer exists. Restart the connect flow.");
|
|
50071
50464
|
}
|
|
50072
50465
|
if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
|
|
50466
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
50073
50467
|
return reply.status(400).send(
|
|
50074
50468
|
`Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
|
|
50075
50469
|
);
|
|
@@ -50078,6 +50472,7 @@ async function googleRoutes(app, opts) {
|
|
|
50078
50472
|
try {
|
|
50079
50473
|
tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
50080
50474
|
} catch (err) {
|
|
50475
|
+
attempt.failed(err);
|
|
50081
50476
|
const msg = describeError(err);
|
|
50082
50477
|
return reply.type("text/html").send(
|
|
50083
50478
|
`<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
@@ -50095,6 +50490,7 @@ async function googleRoutes(app, opts) {
|
|
|
50095
50490
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
50096
50491
|
const existing = store.getConnection(domain, type);
|
|
50097
50492
|
if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
|
|
50493
|
+
attempt.failed(void 0, "ALREADY_CONNECTED");
|
|
50098
50494
|
return reply.status(403).send(
|
|
50099
50495
|
`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
50496
|
);
|
|
@@ -50121,6 +50517,7 @@ async function googleRoutes(app, opts) {
|
|
|
50121
50517
|
entityId: type,
|
|
50122
50518
|
diff: { domain, type, propertyId }
|
|
50123
50519
|
});
|
|
50520
|
+
attempt.succeeded();
|
|
50124
50521
|
return reply.type("text/html").send(googleOAuthSuccessHtml(type));
|
|
50125
50522
|
}
|
|
50126
50523
|
app.get("/google/callback", async (request, reply) => {
|
|
@@ -50129,12 +50526,16 @@ async function googleRoutes(app, opts) {
|
|
|
50129
50526
|
app.get("/projects/:name/google/callback", async (request, reply) => {
|
|
50130
50527
|
return handleOAuthCallback(request, reply);
|
|
50131
50528
|
});
|
|
50132
|
-
app.delete("/projects/:name/google/connections/:type",
|
|
50529
|
+
app.delete("/projects/:name/google/connections/:type", connectionRoute(app, (request) => {
|
|
50530
|
+
const integration = googleIntegration(request.params.type);
|
|
50531
|
+
return integration ? { integration, action: "disconnect" } : void 0;
|
|
50532
|
+
}, async (request, reply, attempt) => {
|
|
50133
50533
|
const store = requireConnectionStore();
|
|
50134
50534
|
const project = resolveProject(app.db, request.params.name);
|
|
50135
50535
|
const type = request.params.type;
|
|
50136
50536
|
const existing = store.getConnection(project.canonicalDomain, type);
|
|
50137
50537
|
if (!existing) {
|
|
50538
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50138
50539
|
throw notFound("Google connection", type);
|
|
50139
50540
|
}
|
|
50140
50541
|
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
@@ -50144,6 +50545,7 @@ async function googleRoutes(app, opts) {
|
|
|
50144
50545
|
}
|
|
50145
50546
|
const deleted = store.deleteConnection(project.canonicalDomain, type);
|
|
50146
50547
|
if (!deleted) {
|
|
50548
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50147
50549
|
throw notFound("Google connection", type);
|
|
50148
50550
|
}
|
|
50149
50551
|
writeAuditLog(app.db, {
|
|
@@ -50154,7 +50556,7 @@ async function googleRoutes(app, opts) {
|
|
|
50154
50556
|
entityId: type
|
|
50155
50557
|
});
|
|
50156
50558
|
return reply.status(204).send();
|
|
50157
|
-
});
|
|
50559
|
+
}));
|
|
50158
50560
|
app.get("/projects/:name/google/properties", async (request) => {
|
|
50159
50561
|
assertNotProjectScoped(request, "listing Search Console properties");
|
|
50160
50562
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
@@ -50472,14 +50874,16 @@ async function googleRoutes(app, opts) {
|
|
|
50472
50874
|
if (!url) {
|
|
50473
50875
|
throw validationError("url is required");
|
|
50474
50876
|
}
|
|
50877
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "inspect");
|
|
50475
50878
|
let result;
|
|
50476
50879
|
try {
|
|
50477
50880
|
const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
50478
50881
|
if (!propertyId) {
|
|
50479
|
-
throw validationError("No GSC property configured for this connection");
|
|
50882
|
+
throw outcome2.refuse(OutcomeReasonCodes.PROPERTY_NOT_FOUND, validationError("No GSC property configured for this connection"));
|
|
50480
50883
|
}
|
|
50481
50884
|
result = await inspectUrl(accessToken, url, propertyId);
|
|
50482
50885
|
} catch (err) {
|
|
50886
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
|
|
50483
50887
|
throw gscErrorToAppError(err, "Failed to inspect URL in Search Console");
|
|
50484
50888
|
}
|
|
50485
50889
|
const ir = result.inspectionResult;
|
|
@@ -50506,6 +50910,7 @@ async function googleRoutes(app, opts) {
|
|
|
50506
50910
|
inspectedAt: now,
|
|
50507
50911
|
createdAt: now
|
|
50508
50912
|
}).run();
|
|
50913
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
|
|
50509
50914
|
return {
|
|
50510
50915
|
id,
|
|
50511
50916
|
url,
|
|
@@ -50730,31 +51135,43 @@ async function googleRoutes(app, opts) {
|
|
|
50730
51135
|
const sitemapUrls = [...new Set(parsed.data.sitemapUrls)];
|
|
50731
51136
|
const project = resolveProject(app.db, request.params.name);
|
|
50732
51137
|
const store = requireConnectionStore();
|
|
51138
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "sitemap_submit");
|
|
50733
51139
|
const conn = store.getConnection(project.canonicalDomain, "gsc");
|
|
50734
51140
|
if (!conn) {
|
|
50735
|
-
throw validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
|
|
51141
|
+
throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError('No GSC connection found for this domain. Run "canonry google connect" first.'));
|
|
50736
51142
|
}
|
|
50737
51143
|
if (!conn.propertyId) {
|
|
50738
|
-
throw
|
|
51144
|
+
throw outcome2.refuse(
|
|
51145
|
+
OutcomeReasonCodes.PROPERTY_NOT_FOUND,
|
|
51146
|
+
validationError('No GSC property configured for this connection. Set one with "canonry google set-property".')
|
|
51147
|
+
);
|
|
50739
51148
|
}
|
|
50740
51149
|
const invalidUrls = sitemapUrls.filter((url) => !isSitemapOwnedByProperty(url, conn.propertyId, project.canonicalDomain));
|
|
50741
51150
|
if (invalidUrls.length > 0) {
|
|
50742
|
-
throw
|
|
51151
|
+
throw outcome2.refuse(
|
|
51152
|
+
OutcomeReasonCodes.VALIDATION,
|
|
51153
|
+
validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`)
|
|
51154
|
+
);
|
|
50743
51155
|
}
|
|
50744
51156
|
if (!(conn.scopes ?? []).includes(GSC_SCOPE)) {
|
|
50745
|
-
throw
|
|
51157
|
+
throw outcome2.refuse(
|
|
51158
|
+
OutcomeReasonCodes.PERMISSION_MISSING,
|
|
51159
|
+
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.')
|
|
51160
|
+
);
|
|
50746
51161
|
}
|
|
50747
51162
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50748
51163
|
if (!googleClientId || !googleClientSecret) {
|
|
50749
|
-
throw validationError("Google OAuth is not configured");
|
|
51164
|
+
throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError("Google OAuth is not configured"));
|
|
50750
51165
|
}
|
|
50751
51166
|
let accessToken;
|
|
50752
51167
|
try {
|
|
50753
51168
|
({ accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret));
|
|
50754
51169
|
} catch (err) {
|
|
51170
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
|
|
50755
51171
|
throw gscErrorToAppError(err, "Failed to authorize Search Console sitemap submission");
|
|
50756
51172
|
}
|
|
50757
51173
|
const results = [];
|
|
51174
|
+
let lastSubmitError;
|
|
50758
51175
|
for (const sitemapUrl of sitemapUrls) {
|
|
50759
51176
|
try {
|
|
50760
51177
|
await submitSitemap(accessToken, conn.propertyId, sitemapUrl);
|
|
@@ -50768,11 +51185,15 @@ async function googleRoutes(app, opts) {
|
|
|
50768
51185
|
});
|
|
50769
51186
|
results.push({ sitemapUrl, status: "accepted", submittedAt });
|
|
50770
51187
|
} catch (err) {
|
|
51188
|
+
lastSubmitError = err;
|
|
50771
51189
|
results.push({ sitemapUrl, status: "error", error: describeError(err) });
|
|
50772
51190
|
}
|
|
50773
51191
|
}
|
|
50774
51192
|
const accepted = results.filter((result) => result.status === "accepted").length;
|
|
50775
|
-
|
|
51193
|
+
const failed = results.length - accepted;
|
|
51194
|
+
const counts2 = { urls: accepted, failures: failed };
|
|
51195
|
+
outcome2.report(failed === 0 ? { status: OutcomeStatuses.succeeded, counts: counts2 } : { status: accepted > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, ...gscOutcomeFailure(lastSubmitError), counts: counts2 });
|
|
51196
|
+
return { summary: { total: results.length, accepted, failed }, results };
|
|
50776
51197
|
});
|
|
50777
51198
|
app.post("/projects/:name/google/gsc/discover-sitemaps", async (request) => {
|
|
50778
51199
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
@@ -50866,7 +51287,10 @@ async function googleRoutes(app, opts) {
|
|
|
50866
51287
|
}
|
|
50867
51288
|
return { sitemapUrl: sitemapUrl.trim() };
|
|
50868
51289
|
});
|
|
50869
|
-
app.put("/projects/:name/google/connections/:type/property",
|
|
51290
|
+
app.put("/projects/:name/google/connections/:type/property", connectionRoute(app, (request) => {
|
|
51291
|
+
const integration = googleIntegration(request.params.type);
|
|
51292
|
+
return integration ? { integration, action: "select" } : void 0;
|
|
51293
|
+
}, async (request, _reply, attempt) => {
|
|
50870
51294
|
const store = requireConnectionStore();
|
|
50871
51295
|
const project = resolveProject(app.db, request.params.name);
|
|
50872
51296
|
const { propertyId } = request.body ?? {};
|
|
@@ -50879,10 +51303,11 @@ async function googleRoutes(app, opts) {
|
|
|
50879
51303
|
{ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
50880
51304
|
);
|
|
50881
51305
|
if (!conn) {
|
|
51306
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
50882
51307
|
throw notFound("Google connection", request.params.type);
|
|
50883
51308
|
}
|
|
50884
51309
|
return { propertyId };
|
|
50885
|
-
});
|
|
51310
|
+
}));
|
|
50886
51311
|
app.post("/projects/:name/google/indexing/request", async (request) => {
|
|
50887
51312
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
50888
51313
|
if (!googleClientId || !googleClientSecret) {
|
|
@@ -51197,7 +51622,7 @@ async function googleRoutes(app, opts) {
|
|
|
51197
51622
|
}
|
|
51198
51623
|
return response;
|
|
51199
51624
|
});
|
|
51200
|
-
app.put("/projects/:name/gbp/locations/:locationName/selection", async (request) => {
|
|
51625
|
+
app.put("/projects/:name/gbp/locations/:locationName/selection", connectionRoute(app, () => ({ integration: "gbp", action: "select" }), async (request) => {
|
|
51201
51626
|
const project = resolveProject(app.db, request.params.name);
|
|
51202
51627
|
const locationName = decodeURIComponent(request.params.locationName);
|
|
51203
51628
|
const parsed = gbpLocationSelectionRequestSchema.safeParse(request.body ?? {});
|
|
@@ -51220,8 +51645,8 @@ async function googleRoutes(app, opts) {
|
|
|
51220
51645
|
});
|
|
51221
51646
|
const refreshed = app.db.select().from(gbpLocations).where(eq57(gbpLocations.id, existing.id)).get();
|
|
51222
51647
|
return rowToDto2(refreshed);
|
|
51223
|
-
});
|
|
51224
|
-
app.delete("/projects/:name/gbp/connection", async (request, reply) => {
|
|
51648
|
+
}));
|
|
51649
|
+
app.delete("/projects/:name/gbp/connection", connectionRoute(app, () => ({ integration: "gbp", action: "disconnect" }), async (request, reply, attempt) => {
|
|
51225
51650
|
const project = resolveProject(app.db, request.params.name);
|
|
51226
51651
|
const store = requireConnectionStore();
|
|
51227
51652
|
app.db.transaction((tx) => {
|
|
@@ -51233,9 +51658,9 @@ async function googleRoutes(app, opts) {
|
|
|
51233
51658
|
entityType: "gbp_connection"
|
|
51234
51659
|
});
|
|
51235
51660
|
});
|
|
51236
|
-
store.deleteConnection(project.canonicalDomain, "gbp");
|
|
51661
|
+
if (!store.deleteConnection(project.canonicalDomain, "gbp")) attempt.cancelled("NOT_CONNECTED");
|
|
51237
51662
|
return reply.status(204).send();
|
|
51238
|
-
});
|
|
51663
|
+
}));
|
|
51239
51664
|
app.post("/projects/:name/gbp/sync", async (request) => {
|
|
51240
51665
|
const project = resolveProject(app.db, request.params.name);
|
|
51241
51666
|
const store = requireConnectionStore();
|
|
@@ -51500,6 +51925,10 @@ var oauthStateSchema = z4.object({
|
|
|
51500
51925
|
}).strict();
|
|
51501
51926
|
var GOOGLE_MARKETING_OAUTH_PENDING_FLOW_MAX = 256;
|
|
51502
51927
|
var GOOGLE_MARKETING_OAUTH_COOKIE_PREFIX = "canonry_google_marketing_oauth_";
|
|
51928
|
+
var MARKETING_INTEGRATIONS = { "google-ads": "google_ads", gtm: "gtm" };
|
|
51929
|
+
function marketingConnection(provider, action = "connect") {
|
|
51930
|
+
return typeof provider === "string" && Object.hasOwn(MARKETING_INTEGRATIONS, provider) ? { integration: MARKETING_INTEGRATIONS[provider], action } : void 0;
|
|
51931
|
+
}
|
|
51503
51932
|
function oauthInitiator(request) {
|
|
51504
51933
|
const principal = request.principal;
|
|
51505
51934
|
return {
|
|
@@ -52226,7 +52655,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52226
52655
|
});
|
|
52227
52656
|
if (stateSecret) {
|
|
52228
52657
|
const registerOAuthConnect = (path8, provider) => {
|
|
52229
|
-
app.post(path8, async (request, reply) => {
|
|
52658
|
+
app.post(path8, connectionRoute(app, () => marketingConnection(provider), async (request, reply, attempt) => {
|
|
52230
52659
|
requireWrite(request);
|
|
52231
52660
|
if (request.principal && !request.principal.viaCookie) {
|
|
52232
52661
|
throw forbidden("Google Marketing OAuth must be started from a signed-in browser.");
|
|
@@ -52240,6 +52669,8 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52240
52669
|
}
|
|
52241
52670
|
const project = resolveProject(app.db, request.params.name);
|
|
52242
52671
|
const store = requireCredentialStore(opts);
|
|
52672
|
+
const connectionAction = telemetryRead(() => store.get(asProjectRef(project), provider), void 0) ? "reauth" : "connect";
|
|
52673
|
+
attempt.update({ action: connectionAction });
|
|
52243
52674
|
const adapter = opts.googleMarketingOAuth;
|
|
52244
52675
|
if (!adapter) throw notImplemented("Google Marketing OAuth is not configured for this deployment.");
|
|
52245
52676
|
const scopes = opts.googleMarketingOAuthScopes?.[provider];
|
|
@@ -52281,6 +52712,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52281
52712
|
expiresAtMs: nowMs + GOOGLE_OAUTH_STATE_MAX_AGE_MS,
|
|
52282
52713
|
initiator: oauthInitiator(request),
|
|
52283
52714
|
browserNonce,
|
|
52715
|
+
connectionAction,
|
|
52284
52716
|
...suppliedDeveloperToken ? { developerToken: suppliedDeveloperToken } : {}
|
|
52285
52717
|
}, nowMs);
|
|
52286
52718
|
appendSetCookie(reply, serializeOAuthBindingCookie({
|
|
@@ -52297,28 +52729,34 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52297
52729
|
entityId: provider,
|
|
52298
52730
|
diff: { provider }
|
|
52299
52731
|
}));
|
|
52732
|
+
attempt.started();
|
|
52300
52733
|
return {
|
|
52301
52734
|
provider,
|
|
52302
52735
|
authorizationUrl,
|
|
52303
52736
|
redirectUri,
|
|
52304
52737
|
expiresAt: null
|
|
52305
52738
|
};
|
|
52306
|
-
});
|
|
52739
|
+
}));
|
|
52307
52740
|
};
|
|
52308
52741
|
registerOAuthConnect("/projects/:name/google-ads/oauth/connect", GoogleMarketingProviders["google-ads"]);
|
|
52309
52742
|
registerOAuthConnect("/projects/:name/gtm/oauth/connect", GoogleMarketingProviders.gtm);
|
|
52310
|
-
app.get("/google-marketing/callback",
|
|
52743
|
+
app.get("/google-marketing/callback", connectionRoute(app, (request) => {
|
|
52744
|
+
const verified = oauthStateSchema.safeParse(verifySignedGoogleOAuthState(request.query.state ?? "", stateSecret));
|
|
52745
|
+
return marketingConnection(verified.success ? verified.data.provider : unverifiedOAuthStateField(request.query.state, "provider"));
|
|
52746
|
+
}, async (request, reply, attempt) => {
|
|
52311
52747
|
if (!request.query.state) {
|
|
52312
52748
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth state."));
|
|
52313
52749
|
}
|
|
52314
52750
|
const state = verifySignedGoogleOAuthState(request.query.state, stateSecret);
|
|
52315
52751
|
const parsedState = oauthStateSchema.safeParse(state);
|
|
52316
52752
|
if (!parsedState.success) {
|
|
52753
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52317
52754
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Invalid, expired, or tampered OAuth state."));
|
|
52318
52755
|
}
|
|
52319
52756
|
const browserNonce = parseCookieHeader(request.headers.cookie)[oauthBindingCookieName(parsedState.data.nonce)];
|
|
52320
52757
|
const pending = pendingOAuthFlows.consume(parsedState.data, browserNonce);
|
|
52321
52758
|
if (!pending) {
|
|
52759
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52322
52760
|
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
52761
|
}
|
|
52324
52762
|
appendSetCookie(reply, serializeOAuthBindingCookie({
|
|
@@ -52327,39 +52765,48 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52327
52765
|
secure: new URL(parsedState.data.redirectUri).protocol === "https:",
|
|
52328
52766
|
opts
|
|
52329
52767
|
}));
|
|
52768
|
+
attempt.update({ action: pending.connectionAction ?? "connect" });
|
|
52330
52769
|
if (request.query.error) {
|
|
52331
52770
|
pendingOAuthFlows.finish(pending);
|
|
52771
|
+
if (request.query.error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
|
|
52772
|
+
else attempt.failed(void 0, "AUTH_DENIED");
|
|
52332
52773
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not approve this authorization request."));
|
|
52333
52774
|
}
|
|
52334
52775
|
if (!request.query.code) {
|
|
52335
52776
|
pendingOAuthFlows.finish(pending);
|
|
52777
|
+
attempt.failed(void 0, "VALIDATION");
|
|
52336
52778
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth code."));
|
|
52337
52779
|
}
|
|
52338
52780
|
const { projectId, projectName, provider, redirectUri } = parsedState.data;
|
|
52339
52781
|
const project = app.db.select().from(projects).where(eq58(projects.id, projectId)).get();
|
|
52340
52782
|
if (!project || project.name !== projectName) {
|
|
52341
52783
|
pendingOAuthFlows.finish(pending);
|
|
52784
|
+
attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
|
|
52342
52785
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
|
|
52343
52786
|
}
|
|
52344
52787
|
const store = opts.googleMarketingCredentialStore;
|
|
52345
52788
|
const adapter = opts.googleMarketingOAuth;
|
|
52346
52789
|
if (!store || !adapter) {
|
|
52347
52790
|
pendingOAuthFlows.finish(pending);
|
|
52791
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
52348
52792
|
return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
|
|
52349
52793
|
}
|
|
52350
52794
|
let tokens;
|
|
52351
52795
|
try {
|
|
52352
52796
|
tokens = await adapter.exchangeCode({ provider, code: request.query.code, redirectUri });
|
|
52353
|
-
} catch {
|
|
52797
|
+
} catch (err) {
|
|
52354
52798
|
pendingOAuthFlows.finish(pending);
|
|
52799
|
+
attempt.failed(err);
|
|
52355
52800
|
return reply.status(502).type("text/html").send(oauthHtml("Authorization failed", "Google rejected the OAuth code. Restart the connection flow."));
|
|
52356
52801
|
}
|
|
52357
52802
|
if (!pendingOAuthFlows.isCurrent(pending)) {
|
|
52358
52803
|
pendingOAuthFlows.finish(pending);
|
|
52804
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52359
52805
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52360
52806
|
}
|
|
52361
52807
|
if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.trim().length === 0) {
|
|
52362
52808
|
pendingOAuthFlows.finish(pending);
|
|
52809
|
+
attempt.failed(void 0, "PERMISSION_MISSING");
|
|
52363
52810
|
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
52811
|
}
|
|
52365
52812
|
let confirmationId;
|
|
@@ -52367,19 +52814,22 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52367
52814
|
confirmationId = pendingOAuthFlows.createConfirmation(pending, tokens);
|
|
52368
52815
|
} catch {
|
|
52369
52816
|
pendingOAuthFlows.finish(pending);
|
|
52817
|
+
attempt.failed(void 0, "RATE_LIMITED");
|
|
52370
52818
|
return reply.status(503).type("text/html").send(oauthHtml("Authorization failed", "Too many pending OAuth confirmations. Restart the connection flow."));
|
|
52371
52819
|
}
|
|
52372
52820
|
if (!confirmationId) {
|
|
52373
52821
|
pendingOAuthFlows.finish(pending);
|
|
52822
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52374
52823
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52375
52824
|
}
|
|
52825
|
+
attempt.deferred();
|
|
52376
52826
|
return reply.type("text/html").send(oauthConfirmationHtml({
|
|
52377
52827
|
provider,
|
|
52378
52828
|
projectName: project.name,
|
|
52379
52829
|
confirmationId,
|
|
52380
52830
|
opts
|
|
52381
52831
|
}));
|
|
52382
|
-
});
|
|
52832
|
+
}));
|
|
52383
52833
|
app.post("/google-marketing/callback/confirm/:confirmationId", async (request, reply) => {
|
|
52384
52834
|
if (request.principal && !request.principal.viaCookie) {
|
|
52385
52835
|
throw forbidden("Google Marketing OAuth confirmation requires the browser that started the connection.");
|
|
@@ -52395,18 +52845,22 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52395
52845
|
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
52846
|
}
|
|
52397
52847
|
const { flow, tokens } = confirmation;
|
|
52848
|
+
const attempt = startConnectionAttempt(app, marketingConnection(flow.provider, flow.connectionAction));
|
|
52398
52849
|
if (!pendingOAuthFlows.isCurrent(flow)) {
|
|
52399
52850
|
pendingOAuthFlows.finish(flow);
|
|
52851
|
+
attempt.failed(void 0, "OAUTH_STATE_INVALID");
|
|
52400
52852
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
|
|
52401
52853
|
}
|
|
52402
52854
|
const project = app.db.select().from(projects).where(eq58(projects.id, flow.projectId)).get();
|
|
52403
52855
|
if (!project || project.name !== flow.projectName) {
|
|
52404
52856
|
pendingOAuthFlows.finish(flow);
|
|
52857
|
+
attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
|
|
52405
52858
|
return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
|
|
52406
52859
|
}
|
|
52407
52860
|
const store = opts.googleMarketingCredentialStore;
|
|
52408
52861
|
if (!store) {
|
|
52409
52862
|
pendingOAuthFlows.finish(flow);
|
|
52863
|
+
attempt.failed(void 0, "UNSUPPORTED");
|
|
52410
52864
|
return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
|
|
52411
52865
|
}
|
|
52412
52866
|
const projectRef = asProjectRef(project);
|
|
@@ -52494,6 +52948,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52494
52948
|
});
|
|
52495
52949
|
});
|
|
52496
52950
|
} catch (error) {
|
|
52951
|
+
attempt.failed(error);
|
|
52497
52952
|
if (rollbackCredential) {
|
|
52498
52953
|
try {
|
|
52499
52954
|
rollbackCredential();
|
|
@@ -52505,6 +52960,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52505
52960
|
} finally {
|
|
52506
52961
|
pendingOAuthFlows.finish(flow);
|
|
52507
52962
|
}
|
|
52963
|
+
attempt.succeeded();
|
|
52508
52964
|
const label = flow.provider === GoogleMarketingProviders["google-ads"] ? "Google Ads" : "Google Tag Manager";
|
|
52509
52965
|
return reply.type("text/html").send(oauthHtml("Connected successfully", `${label} is now linked to this project.`));
|
|
52510
52966
|
});
|
|
@@ -52595,16 +53051,24 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52595
53051
|
enqueueAfterCommit(app, callback, run.id, projectId, kind);
|
|
52596
53052
|
return run.id;
|
|
52597
53053
|
};
|
|
52598
|
-
app.put("/projects/:name/google-ads/selection", async (request) => {
|
|
53054
|
+
app.put("/projects/:name/google-ads/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "select"), async (request, _reply, attempt) => {
|
|
52599
53055
|
requireWrite(request);
|
|
52600
53056
|
const parsedBody = parseBody3(googleAdsCustomerSelectionRequestSchema, request.body, "Google Ads selection");
|
|
52601
53057
|
const body = canonicalizeGoogleAdsCustomerSelection(parsedBody);
|
|
52602
53058
|
if (!body) throw validationError("Invalid Google Ads selection.");
|
|
52603
53059
|
const project = resolveProject(app.db, request.params.name);
|
|
52604
53060
|
const projectRef = asProjectRef(project);
|
|
52605
|
-
|
|
53061
|
+
try {
|
|
53062
|
+
requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
|
|
53063
|
+
} catch (err) {
|
|
53064
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
53065
|
+
throw err;
|
|
53066
|
+
}
|
|
52606
53067
|
const existing = app.db.select().from(googleAdsConnections).where(eq58(googleAdsConnections.projectId, project.id)).get();
|
|
52607
|
-
if (!existing)
|
|
53068
|
+
if (!existing) {
|
|
53069
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
53070
|
+
throw validationError("Connect Google Ads before selecting a customer.");
|
|
53071
|
+
}
|
|
52608
53072
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
52609
53073
|
const customerChanged = existing.selectedCustomerId !== body.customerId;
|
|
52610
53074
|
app.db.transaction((tx) => {
|
|
@@ -52637,17 +53101,25 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52637
53101
|
});
|
|
52638
53102
|
queueSelectionSync(request, project.id, "google-ads-sync", opts.onGoogleAdsSyncRequested);
|
|
52639
53103
|
return googleAdsStatus(app, opts, projectRef);
|
|
52640
|
-
});
|
|
52641
|
-
app.put("/projects/:name/gtm/selection", async (request) => {
|
|
53104
|
+
}));
|
|
53105
|
+
app.put("/projects/:name/gtm/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "select"), async (request, _reply, attempt) => {
|
|
52642
53106
|
requireWrite(request);
|
|
52643
53107
|
const parsedBody = parseBody3(gtmResourceSelectionRequestSchema, request.body, "GTM selection");
|
|
52644
53108
|
const body = canonicalizeGtmResourceSelection(parsedBody);
|
|
52645
53109
|
if (!body) throw validationError("Invalid GTM selection.");
|
|
52646
53110
|
const project = resolveProject(app.db, request.params.name);
|
|
52647
53111
|
const projectRef = asProjectRef(project);
|
|
52648
|
-
|
|
53112
|
+
try {
|
|
53113
|
+
requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
|
|
53114
|
+
} catch (err) {
|
|
53115
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
53116
|
+
throw err;
|
|
53117
|
+
}
|
|
52649
53118
|
const existing = app.db.select().from(gtmConnections).where(eq58(gtmConnections.projectId, project.id)).get();
|
|
52650
|
-
if (!existing)
|
|
53119
|
+
if (!existing) {
|
|
53120
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
53121
|
+
throw validationError("Connect Google Tag Manager before selecting resources.");
|
|
53122
|
+
}
|
|
52651
53123
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
52652
53124
|
const selectedWorkspaceId = body.workspaceId ?? null;
|
|
52653
53125
|
const containerChanged = existing.selectedAccountId !== body.accountId || existing.selectedContainerId !== body.containerId;
|
|
@@ -52681,7 +53153,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52681
53153
|
});
|
|
52682
53154
|
queueSelectionSync(request, project.id, "gtm-sync", opts.onGtmSyncRequested);
|
|
52683
53155
|
return gtmStatus(app, opts, projectRef);
|
|
52684
|
-
});
|
|
53156
|
+
}));
|
|
52685
53157
|
app.post("/projects/:name/google-ads/sync", async (request) => {
|
|
52686
53158
|
requireLiveRead(request);
|
|
52687
53159
|
requireWrite(request);
|
|
@@ -52823,7 +53295,7 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52823
53295
|
if (!snapshot) throw providerError("Stored GTM snapshot is invalid.");
|
|
52824
53296
|
return { snapshot };
|
|
52825
53297
|
});
|
|
52826
|
-
app.delete("/projects/:name/google-ads/connection", async (request) => {
|
|
53298
|
+
app.delete("/projects/:name/google-ads/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "disconnect"), async (request, _reply, attempt) => {
|
|
52827
53299
|
requireWrite(request);
|
|
52828
53300
|
const project = resolveProject(app.db, request.params.name);
|
|
52829
53301
|
const projectRef = asProjectRef(project);
|
|
@@ -52859,9 +53331,10 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52859
53331
|
}));
|
|
52860
53332
|
});
|
|
52861
53333
|
}
|
|
53334
|
+
if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
|
|
52862
53335
|
return { provider: GoogleMarketingProviders["google-ads"], disconnected: Boolean(row) || removedCredential };
|
|
52863
|
-
});
|
|
52864
|
-
app.delete("/projects/:name/gtm/connection", async (request) => {
|
|
53336
|
+
}));
|
|
53337
|
+
app.delete("/projects/:name/gtm/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "disconnect"), async (request, _reply, attempt) => {
|
|
52865
53338
|
requireWrite(request);
|
|
52866
53339
|
const project = resolveProject(app.db, request.params.name);
|
|
52867
53340
|
const projectRef = asProjectRef(project);
|
|
@@ -52895,8 +53368,9 @@ async function googleMarketingRoutes(app, opts) {
|
|
|
52895
53368
|
}));
|
|
52896
53369
|
});
|
|
52897
53370
|
}
|
|
53371
|
+
if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
|
|
52898
53372
|
return { provider: GoogleMarketingProviders.gtm, disconnected: Boolean(row) || removedCredential };
|
|
52899
|
-
});
|
|
53373
|
+
}));
|
|
52900
53374
|
app.get("/projects/:name/conversion-tracking/options", async (request) => {
|
|
52901
53375
|
const project = resolveProject(app.db, request.params.name);
|
|
52902
53376
|
const adsSnapshot = latestGoogleAdsSnapshot(app, project.id);
|
|
@@ -54912,6 +55386,31 @@ function enforceAdsActivationOperationalCap(manifest) {
|
|
|
54912
55386
|
);
|
|
54913
55387
|
}
|
|
54914
55388
|
}
|
|
55389
|
+
var ACTIVATION_FAILURE_REASONS = {
|
|
55390
|
+
[AdsActivationErrorCodes.invalidManifest]: OutcomeReasonCodes.VALIDATION,
|
|
55391
|
+
[AdsActivationErrorCodes.grantNotFound]: OutcomeReasonCodes.NOT_FOUND,
|
|
55392
|
+
[AdsActivationErrorCodes.grantProjectMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55393
|
+
[AdsActivationErrorCodes.grantAccountMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55394
|
+
[AdsActivationErrorCodes.grantHashMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55395
|
+
[AdsActivationErrorCodes.grantExecutorMismatch]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55396
|
+
[AdsActivationErrorCodes.grantExpired]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55397
|
+
[AdsActivationErrorCodes.grantRevoked]: OutcomeReasonCodes.CANCELLED_BY_USER,
|
|
55398
|
+
[AdsActivationErrorCodes.grantUsed]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55399
|
+
[AdsActivationErrorCodes.operationConflict]: OutcomeReasonCodes.VALIDATION,
|
|
55400
|
+
[AdsActivationErrorCodes.accountNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55401
|
+
[AdsActivationErrorCodes.entityMismatch]: OutcomeReasonCodes.VALIDATION,
|
|
55402
|
+
[AdsActivationErrorCodes.entityNotPaused]: OutcomeReasonCodes.VALIDATION,
|
|
55403
|
+
[AdsActivationErrorCodes.entityStale]: OutcomeReasonCodes.VALIDATION,
|
|
55404
|
+
[AdsActivationErrorCodes.adNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
|
|
55405
|
+
[AdsActivationErrorCodes.manualRemediationRequired]: OutcomeReasonCodes.QUARANTINED,
|
|
55406
|
+
[AdsActivationErrorCodes.persistenceFailed]: OutcomeReasonCodes.INTERNAL
|
|
55407
|
+
};
|
|
55408
|
+
function activationFailure(error) {
|
|
55409
|
+
const known = error instanceof AdsActivationError ? ACTIVATION_FAILURE_REASONS[error.code] : void 0;
|
|
55410
|
+
const failure = known ? { ...classifyOutcomeError(error), reasonCode: known } : upstreamFailure(error);
|
|
55411
|
+
const status = failure.reasonCode === OutcomeReasonCodes.QUARANTINED ? OutcomeStatuses.partial : failure.reasonCode === OutcomeReasonCodes.CANCELLED_BY_USER ? OutcomeStatuses.cancelled : OutcomeStatuses.failed;
|
|
55412
|
+
return { status, ...failure };
|
|
55413
|
+
}
|
|
54915
55414
|
function requireAuthenticatedKey(request) {
|
|
54916
55415
|
if (!request.apiKey) {
|
|
54917
55416
|
throw forbidden("Ads activation approval and execution require an authenticated API key");
|
|
@@ -54987,21 +55486,39 @@ function terminalActivationResponse(app, opts, grant, operationKey) {
|
|
|
54987
55486
|
return activationResponse(opts, canonical.grant, canonical.operation);
|
|
54988
55487
|
}
|
|
54989
55488
|
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
|
-
|
|
55489
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation", request ? {} : { trigger: OutcomeTriggers.retry });
|
|
55490
|
+
const store = createActivationStore(app, request);
|
|
55491
|
+
let claimKind;
|
|
55492
|
+
try {
|
|
55493
|
+
const runtime = await opts.resolveRuntime(project);
|
|
55494
|
+
const result = await executeApprovedAdsActivation({
|
|
55495
|
+
store: {
|
|
55496
|
+
...store,
|
|
55497
|
+
claimGrantAndOperation: async (input) => {
|
|
55498
|
+
const claim = await store.claimGrantAndOperation(input);
|
|
55499
|
+
claimKind = claim.kind;
|
|
55500
|
+
return claim;
|
|
55501
|
+
}
|
|
55502
|
+
},
|
|
55503
|
+
provider: runtime.provider,
|
|
55504
|
+
leaseMs: opts.leaseMs ?? ACTIVATION_LEASE_MS
|
|
55505
|
+
}, {
|
|
55506
|
+
projectId: project.id,
|
|
55507
|
+
adAccountId: runtime.adAccountId,
|
|
55508
|
+
operationKey,
|
|
55509
|
+
grantId: grant.id,
|
|
55510
|
+
manifestHash: grant.manifestHash,
|
|
55511
|
+
executorApiKeyId,
|
|
55512
|
+
manifest: grant.manifest
|
|
55513
|
+
});
|
|
55514
|
+
if (claimKind === "claimed" || claimKind === "resumed") {
|
|
55515
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { campaigns: 1, operations: result.steps.length } });
|
|
55516
|
+
}
|
|
55517
|
+
return activationResponse(opts, result.grant, result.operation);
|
|
55518
|
+
} catch (error) {
|
|
55519
|
+
outcome2.report(activationFailure(error));
|
|
55520
|
+
throw error;
|
|
55521
|
+
}
|
|
55005
55522
|
}
|
|
55006
55523
|
function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
|
|
55007
55524
|
const leaseAvailable = or15(
|
|
@@ -55356,7 +55873,8 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55356
55873
|
const approver = requireAuthenticatedKey(request);
|
|
55357
55874
|
const project = resolveProject(app.db, request.params.name);
|
|
55358
55875
|
const body = parseBody4(adsActivationGrantCreateRequestSchema, request.body);
|
|
55359
|
-
|
|
55876
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation");
|
|
55877
|
+
outcome2.guard(OutcomeReasonCodes.GATE_REFUSED, () => enforceAdsActivationOperationalCap(body.manifest));
|
|
55360
55878
|
const now = /* @__PURE__ */ new Date();
|
|
55361
55879
|
const expiresAt = Date.parse(body.expiresAt);
|
|
55362
55880
|
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime() || expiresAt > now.getTime() + MAX_ACTIVATION_GRANT_LIFETIME_MS) {
|
|
@@ -55375,7 +55893,13 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55375
55893
|
if (!executor.scopes.includes("*") && !executor.scopes.includes(ADS_ACTIVATE_SCOPE)) {
|
|
55376
55894
|
throw forbidden(`The executor API key requires the "${ADS_ACTIVATE_SCOPE}" scope`);
|
|
55377
55895
|
}
|
|
55378
|
-
|
|
55896
|
+
let runtime;
|
|
55897
|
+
try {
|
|
55898
|
+
runtime = await opts.resolveRuntime(project);
|
|
55899
|
+
} catch (error) {
|
|
55900
|
+
outcome2.report(activationFailure(error));
|
|
55901
|
+
throw error;
|
|
55902
|
+
}
|
|
55379
55903
|
let preflight;
|
|
55380
55904
|
try {
|
|
55381
55905
|
preflight = body.versionPolicy === AdsActivationVersionPolicies.refreshSemanticallyUnchanged ? await refreshSemanticallyUnchangedAdsActivationManifest(runtime.provider, {
|
|
@@ -55387,6 +55911,7 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55387
55911
|
manifest: body.manifest
|
|
55388
55912
|
});
|
|
55389
55913
|
} catch (error) {
|
|
55914
|
+
outcome2.report(activationFailure(error));
|
|
55390
55915
|
if (error instanceof AdsActivationError) throw mapApprovalPreflightError(error);
|
|
55391
55916
|
throw error;
|
|
55392
55917
|
}
|
|
@@ -55495,6 +56020,9 @@ function registerAdsActivationRoutes(app, opts) {
|
|
|
55495
56020
|
if (grant.revocationRequestedAt !== null && (grant.state === AdsActivationGrantStates.executing || grant.state === AdsActivationGrantStates.unknown)) {
|
|
55496
56021
|
startWatchdogSweep?.();
|
|
55497
56022
|
}
|
|
56023
|
+
if (existing.state === AdsActivationGrantStates.approved && grant.state === AdsActivationGrantStates.revoked) {
|
|
56024
|
+
startRouteOutcome(app, FeatureNames.openai_ads, "activation").report({ status: OutcomeStatuses.cancelled, reasonCode: OutcomeReasonCodes.CANCELLED_BY_USER });
|
|
56025
|
+
}
|
|
55498
56026
|
return adsActivationGrantResponseSchema.parse({ grant });
|
|
55499
56027
|
}
|
|
55500
56028
|
);
|
|
@@ -56268,15 +56796,21 @@ function storedEntityState(row) {
|
|
|
56268
56796
|
};
|
|
56269
56797
|
}
|
|
56270
56798
|
async function executeAdsOperation(app, request, input) {
|
|
56799
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "operation");
|
|
56271
56800
|
const hash2 = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
|
|
56272
56801
|
const existing = readOperationByKey(app, input.projectId, input.operationKey);
|
|
56273
56802
|
if (existing) {
|
|
56274
56803
|
if (existing.requestHash !== hash2) {
|
|
56275
|
-
throw alreadyExists("Ads operation key", input.operationKey);
|
|
56804
|
+
throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
|
|
56276
56805
|
}
|
|
56277
56806
|
return { operation: operationDto(existing), replayed: true };
|
|
56278
56807
|
}
|
|
56279
|
-
|
|
56808
|
+
try {
|
|
56809
|
+
await input.preflight?.();
|
|
56810
|
+
} catch (err) {
|
|
56811
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...upstreamFailure(err) });
|
|
56812
|
+
throw err;
|
|
56813
|
+
}
|
|
56280
56814
|
const id = crypto39.randomUUID();
|
|
56281
56815
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
56282
56816
|
const reconcileFields = input.reconciliation.fields ?? null;
|
|
@@ -56308,10 +56842,10 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56308
56842
|
eq60(adsOperations.operationKey, input.operationKey)
|
|
56309
56843
|
)).get();
|
|
56310
56844
|
if (!canonical) {
|
|
56311
|
-
throw providerError("Ads operation receipt could not be claimed");
|
|
56845
|
+
throw outcome2.refuse(OutcomeReasonCodes.INTERNAL, providerError("Ads operation receipt could not be claimed"));
|
|
56312
56846
|
}
|
|
56313
56847
|
if (canonical.requestHash !== hash2) {
|
|
56314
|
-
throw alreadyExists("Ads operation key", input.operationKey);
|
|
56848
|
+
throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
|
|
56315
56849
|
}
|
|
56316
56850
|
if (inserted.length === 0) {
|
|
56317
56851
|
return { operation: operationDto(canonical), replayed: true };
|
|
@@ -56360,6 +56894,7 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56360
56894
|
}));
|
|
56361
56895
|
}
|
|
56362
56896
|
});
|
|
56897
|
+
outcome2.report({ status: OutcomeStatuses.succeeded });
|
|
56363
56898
|
const row = app.db.select().from(adsOperations).where(eq60(adsOperations.id, id)).get();
|
|
56364
56899
|
return { operation: operationDto(row), replayed: false };
|
|
56365
56900
|
} catch (err) {
|
|
@@ -56390,6 +56925,10 @@ async function executeAdsOperation(app, request, input) {
|
|
|
56390
56925
|
}
|
|
56391
56926
|
return update.changes > 0;
|
|
56392
56927
|
});
|
|
56928
|
+
outcome2.report({
|
|
56929
|
+
status: failure.state === AdsOperationStates.failed ? OutcomeStatuses.failed : OutcomeStatuses.partial,
|
|
56930
|
+
...upstreamFailure(err)
|
|
56931
|
+
});
|
|
56393
56932
|
if (!finalized) {
|
|
56394
56933
|
const canonical2 = app.db.select().from(adsOperations).where(and50(
|
|
56395
56934
|
eq60(adsOperations.id, id),
|
|
@@ -56605,8 +57144,17 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56605
57144
|
maxAttempts: Math.min(20, Math.max(1, input.maxAttempts ?? DEFAULT_RECONCILE_MAX_ATTEMPTS))
|
|
56606
57145
|
};
|
|
56607
57146
|
const context = input.audit ?? { actor: "system" };
|
|
57147
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", context.request ? {} : { trigger: OutcomeTriggers.scheduled });
|
|
57148
|
+
const reportInProgress = () => outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.OPERATION_IN_PROGRESS });
|
|
57149
|
+
const finished = (row, failure) => {
|
|
57150
|
+
if (row.state === AdsOperationStates.succeeded) outcome2.report({ status: OutcomeStatuses.succeeded });
|
|
57151
|
+
else if (row.errorCode === ADS_RECONCILIATION_QUARANTINED) outcome2.report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57152
|
+
else outcome2.report({ status: OutcomeStatuses.failed, ...failure });
|
|
57153
|
+
return reconcileResponse(row);
|
|
57154
|
+
};
|
|
56608
57155
|
const pendingWaitMs = existing.state === AdsOperationStates.pending ? pendingIdleRemainingMs(existing, now, policy.pendingMinIdleMs) : 0;
|
|
56609
57156
|
if (pendingWaitMs > 0) {
|
|
57157
|
+
reportInProgress();
|
|
56610
57158
|
throw operationInProgress(
|
|
56611
57159
|
"The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
|
|
56612
57160
|
{
|
|
@@ -56617,7 +57165,14 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56617
57165
|
);
|
|
56618
57166
|
}
|
|
56619
57167
|
if (existing.reconcileAttempts >= policy.maxAttempts) {
|
|
56620
|
-
|
|
57168
|
+
const row = quarantineExhaustedReconciliation(app, existing, now, policy, context);
|
|
57169
|
+
if (row.errorCode !== ADS_RECONCILIATION_QUARANTINED) {
|
|
57170
|
+
reportInProgress();
|
|
57171
|
+
} else {
|
|
57172
|
+
const already = existing.errorCode === ADS_RECONCILIATION_QUARANTINED;
|
|
57173
|
+
outcome2.report({ status: already ? OutcomeStatuses.skipped : OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57174
|
+
}
|
|
57175
|
+
return reconcileResponse(row);
|
|
56621
57176
|
}
|
|
56622
57177
|
const leaseOwner = `ads-reconcile:${crypto39.randomUUID()}`;
|
|
56623
57178
|
const claimed = claimOperationForReconciliation(
|
|
@@ -56635,6 +57190,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56635
57190
|
if (canonical.state === AdsOperationStates.pending) {
|
|
56636
57191
|
const retryAfterMs = pendingIdleRemainingMs(canonical, now, policy.pendingMinIdleMs);
|
|
56637
57192
|
if (retryAfterMs > 0) {
|
|
57193
|
+
reportInProgress();
|
|
56638
57194
|
throw operationInProgress(
|
|
56639
57195
|
"The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
|
|
56640
57196
|
{
|
|
@@ -56645,6 +57201,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56645
57201
|
);
|
|
56646
57202
|
}
|
|
56647
57203
|
}
|
|
57204
|
+
reportInProgress();
|
|
56648
57205
|
return {
|
|
56649
57206
|
operation: operationDto(canonical),
|
|
56650
57207
|
resolved: canonical.state === AdsOperationStates.succeeded
|
|
@@ -56660,7 +57217,9 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56660
57217
|
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
57218
|
policy.maxAttempts
|
|
56662
57219
|
);
|
|
56663
|
-
return
|
|
57220
|
+
return finished(row, {
|
|
57221
|
+
reasonCode: claimed.adAccountId ? OutcomeReasonCodes.ACCOUNT_NOT_FOUND : OutcomeReasonCodes.UNSUPPORTED
|
|
57222
|
+
});
|
|
56664
57223
|
}
|
|
56665
57224
|
const strategy = claimed.reconcileStrategy;
|
|
56666
57225
|
const desired = claimed.reconcileFields;
|
|
@@ -56674,7 +57233,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56674
57233
|
"This operation cannot be reconciled automatically",
|
|
56675
57234
|
policy.maxAttempts
|
|
56676
57235
|
);
|
|
56677
|
-
return
|
|
57236
|
+
return finished(row, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
56678
57237
|
}
|
|
56679
57238
|
try {
|
|
56680
57239
|
let match;
|
|
@@ -56689,7 +57248,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56689
57248
|
"The create outcome has no checkpointed provider id and cannot be resolved automatically",
|
|
56690
57249
|
policy.maxAttempts
|
|
56691
57250
|
);
|
|
56692
|
-
return
|
|
57251
|
+
return finished(row2, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
56693
57252
|
}
|
|
56694
57253
|
if (checkpointedId) {
|
|
56695
57254
|
const entity = await getEntityForReconciliation(
|
|
@@ -56718,13 +57277,13 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56718
57277
|
"The upstream entity did not match the requested safe state",
|
|
56719
57278
|
policy.maxAttempts
|
|
56720
57279
|
);
|
|
56721
|
-
return
|
|
57280
|
+
return finished(row2, { reasonCode: OutcomeReasonCodes.UNKNOWN });
|
|
56722
57281
|
}
|
|
56723
57282
|
const row = finishReconciliation(app, claimed, leaseOwner, context, {
|
|
56724
57283
|
state: AdsOperationStates.succeeded,
|
|
56725
57284
|
entity: match
|
|
56726
57285
|
}, policy.maxAttempts);
|
|
56727
|
-
return
|
|
57286
|
+
return finished(row, { reasonCode: OutcomeReasonCodes.UNKNOWN });
|
|
56728
57287
|
} catch (error) {
|
|
56729
57288
|
const failure = errorDetails(error);
|
|
56730
57289
|
const row = unknownReconciliation(
|
|
@@ -56736,7 +57295,7 @@ async function reconcileOneAdsOperation(app, input) {
|
|
|
56736
57295
|
"OpenAI Ads API outcome could not be reconciled",
|
|
56737
57296
|
policy.maxAttempts
|
|
56738
57297
|
);
|
|
56739
|
-
return
|
|
57298
|
+
return finished(row, upstreamFailure(error));
|
|
56740
57299
|
}
|
|
56741
57300
|
}
|
|
56742
57301
|
async function sweepAdsOperationsOnce(app, opts, config) {
|
|
@@ -56808,7 +57367,10 @@ async function sweepAdsOperationsOnce(app, opts, config) {
|
|
|
56808
57367
|
)
|
|
56809
57368
|
)).orderBy(asc12(adsOperations.updatedAt)).limit(config.batchSize).all();
|
|
56810
57369
|
for (const row of exhaustedRows) {
|
|
56811
|
-
quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
|
|
57370
|
+
const settled = quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
|
|
57371
|
+
if (settled.errorCode === ADS_RECONCILIATION_QUARANTINED) {
|
|
57372
|
+
startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", { trigger: OutcomeTriggers.scheduled }).report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
|
|
57373
|
+
}
|
|
56812
57374
|
}
|
|
56813
57375
|
const credentialsByProjectId = /* @__PURE__ */ new Map();
|
|
56814
57376
|
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 +57564,7 @@ async function adsRoutes(app, opts) {
|
|
|
57002
57564
|
});
|
|
57003
57565
|
app.post(
|
|
57004
57566
|
"/projects/:name/ads/connect",
|
|
57005
|
-
async (request) => {
|
|
57567
|
+
connectionRoute(app, () => ({ integration: "openai_ads", action: "connect" }), async (request, _reply, attempt) => {
|
|
57006
57568
|
requireScope(request, ADS_WRITE_SCOPE);
|
|
57007
57569
|
const project = resolveProject(app.db, request.params.name);
|
|
57008
57570
|
const parsed = adsConnectRequestSchema.safeParse(request.body);
|
|
@@ -57014,12 +57576,14 @@ async function adsRoutes(app, opts) {
|
|
|
57014
57576
|
try {
|
|
57015
57577
|
account = await opts.verifyAdsAccount(parsed.data.apiKey);
|
|
57016
57578
|
} catch (err) {
|
|
57579
|
+
attempt.failed(err);
|
|
57017
57580
|
const message = describeError(err);
|
|
57018
57581
|
throw validationError(`OpenAI Ads API rejected the key: ${message}`);
|
|
57019
57582
|
}
|
|
57020
57583
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
57021
57584
|
verificationCache.delete(project.id);
|
|
57022
57585
|
const existingCfg = opts.adsCredentialStore.getConnection(project.name);
|
|
57586
|
+
if (existingCfg) attempt.update({ action: "reauth" });
|
|
57023
57587
|
opts.adsCredentialStore.upsertConnection({
|
|
57024
57588
|
projectName: project.name,
|
|
57025
57589
|
apiKey: parsed.data.apiKey,
|
|
@@ -57071,9 +57635,9 @@ async function adsRoutes(app, opts) {
|
|
|
57071
57635
|
});
|
|
57072
57636
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
57073
57637
|
return statusDto(row);
|
|
57074
|
-
}
|
|
57638
|
+
})
|
|
57075
57639
|
);
|
|
57076
|
-
app.delete("/projects/:name/ads/connection", async (request) => {
|
|
57640
|
+
app.delete("/projects/:name/ads/connection", connectionRoute(app, () => ({ integration: "openai_ads", action: "disconnect" }), async (request, _reply, attempt) => {
|
|
57077
57641
|
requireScope(request, ADS_WRITE_SCOPE);
|
|
57078
57642
|
const project = resolveProject(app.db, request.params.name);
|
|
57079
57643
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
@@ -57092,8 +57656,9 @@ async function adsRoutes(app, opts) {
|
|
|
57092
57656
|
const removedFromConfig = opts.adsCredentialStore?.removeConnection(project.name) ?? false;
|
|
57093
57657
|
verificationCache.delete(project.id);
|
|
57094
57658
|
const response = { disconnected: Boolean(row) || removedFromConfig };
|
|
57659
|
+
if (!response.disconnected) attempt.cancelled("NOT_CONNECTED");
|
|
57095
57660
|
return response;
|
|
57096
|
-
});
|
|
57661
|
+
}));
|
|
57097
57662
|
app.get("/projects/:name/ads/status", async (request) => {
|
|
57098
57663
|
const project = resolveProject(app.db, request.params.name);
|
|
57099
57664
|
const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
|
|
@@ -58426,7 +58991,7 @@ async function bingRoutes(app, opts) {
|
|
|
58426
58991
|
}
|
|
58427
58992
|
return conn;
|
|
58428
58993
|
}
|
|
58429
|
-
app.post("/projects/:name/bing/connect", async (request) => {
|
|
58994
|
+
app.post("/projects/:name/bing/connect", connectionRoute(app, () => ({ integration: "bing", action: "connect" }), async (request, _reply, attempt) => {
|
|
58430
58995
|
const store = requireConnectionStore();
|
|
58431
58996
|
const { apiKey } = request.body ?? {};
|
|
58432
58997
|
if (!apiKey || typeof apiKey !== "string") {
|
|
@@ -58440,11 +59005,14 @@ async function bingRoutes(app, opts) {
|
|
|
58440
59005
|
} catch (e) {
|
|
58441
59006
|
const msg = describeError(e);
|
|
58442
59007
|
bingLog("error", "connect.verify-key-failed", { domain: project.canonicalDomain, error: msg });
|
|
59008
|
+
attempt.failed(e);
|
|
58443
59009
|
throw validationError(`Failed to verify Bing API key: ${msg}`);
|
|
58444
59010
|
}
|
|
58445
59011
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
58446
59012
|
const existing = store.getConnection(project.canonicalDomain);
|
|
59013
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
58447
59014
|
if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
59015
|
+
attempt.failed(void 0, "ALREADY_CONNECTED");
|
|
58448
59016
|
throw validationError(
|
|
58449
59017
|
`This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
|
|
58450
59018
|
);
|
|
@@ -58470,12 +59038,13 @@ async function bingRoutes(app, opts) {
|
|
|
58470
59038
|
siteUrl: existing?.siteUrl ?? null,
|
|
58471
59039
|
availableSites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false }))
|
|
58472
59040
|
};
|
|
58473
|
-
});
|
|
58474
|
-
app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
|
|
59041
|
+
}));
|
|
59042
|
+
app.delete("/projects/:name/bing/disconnect", connectionRoute(app, () => ({ integration: "bing", action: "disconnect" }), async (request, reply, attempt) => {
|
|
58475
59043
|
const store = requireConnectionStore();
|
|
58476
59044
|
const project = resolveProject(app.db, request.params.name);
|
|
58477
59045
|
const existing = store.getConnection(project.canonicalDomain);
|
|
58478
59046
|
if (!existing) {
|
|
59047
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
58479
59048
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
58480
59049
|
}
|
|
58481
59050
|
if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
|
|
@@ -58485,6 +59054,7 @@ async function bingRoutes(app, opts) {
|
|
|
58485
59054
|
}
|
|
58486
59055
|
const deleted = store.deleteConnection(project.canonicalDomain);
|
|
58487
59056
|
if (!deleted) {
|
|
59057
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
58488
59058
|
throw notFound("Bing connection", project.canonicalDomain);
|
|
58489
59059
|
}
|
|
58490
59060
|
writeAuditLog(app.db, {
|
|
@@ -58495,7 +59065,7 @@ async function bingRoutes(app, opts) {
|
|
|
58495
59065
|
entityId: project.canonicalDomain
|
|
58496
59066
|
});
|
|
58497
59067
|
return reply.status(204).send();
|
|
58498
|
-
});
|
|
59068
|
+
}));
|
|
58499
59069
|
app.get("/projects/:name/bing/status", async (request) => {
|
|
58500
59070
|
const store = requireConnectionStore();
|
|
58501
59071
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -58516,10 +59086,15 @@ async function bingRoutes(app, opts) {
|
|
|
58516
59086
|
const sites = await getSites(conn.apiKey);
|
|
58517
59087
|
return { sites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false })) };
|
|
58518
59088
|
});
|
|
58519
|
-
app.post("/projects/:name/bing/set-site", async (request) => {
|
|
59089
|
+
app.post("/projects/:name/bing/set-site", connectionRoute(app, () => ({ integration: "bing", action: "select" }), async (request, _reply, attempt) => {
|
|
58520
59090
|
const store = requireConnectionStore();
|
|
58521
59091
|
const project = resolveProject(app.db, request.params.name);
|
|
58522
|
-
|
|
59092
|
+
try {
|
|
59093
|
+
requireConnection(store, project.canonicalDomain);
|
|
59094
|
+
} catch (err) {
|
|
59095
|
+
attempt.failed(err, "NOT_CONNECTED");
|
|
59096
|
+
throw err;
|
|
59097
|
+
}
|
|
58523
59098
|
const { siteUrl } = request.body ?? {};
|
|
58524
59099
|
if (!siteUrl || typeof siteUrl !== "string") {
|
|
58525
59100
|
throw validationError("siteUrl is required");
|
|
@@ -58529,7 +59104,7 @@ async function bingRoutes(app, opts) {
|
|
|
58529
59104
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
58530
59105
|
});
|
|
58531
59106
|
return { siteUrl };
|
|
58532
|
-
});
|
|
59107
|
+
}));
|
|
58533
59108
|
app.get("/projects/:name/bing/coverage", async (request) => {
|
|
58534
59109
|
const store = requireConnectionStore();
|
|
58535
59110
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -58653,9 +59228,13 @@ async function bingRoutes(app, opts) {
|
|
|
58653
59228
|
app.post("/projects/:name/bing/inspect-url", async (request) => {
|
|
58654
59229
|
const store = requireConnectionStore();
|
|
58655
59230
|
const project = resolveProject(app.db, request.params.name);
|
|
58656
|
-
const
|
|
59231
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.bing, "inspect");
|
|
59232
|
+
const conn = outcome2.guard(OutcomeReasonCodes.NOT_CONNECTED, () => requireConnection(store, project.canonicalDomain));
|
|
58657
59233
|
if (!conn.siteUrl) {
|
|
58658
|
-
throw
|
|
59234
|
+
throw outcome2.refuse(
|
|
59235
|
+
OutcomeReasonCodes.PROPERTY_NOT_FOUND,
|
|
59236
|
+
validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.')
|
|
59237
|
+
);
|
|
58659
59238
|
}
|
|
58660
59239
|
const { url } = request.body ?? {};
|
|
58661
59240
|
if (!url) {
|
|
@@ -58726,6 +59305,7 @@ async function bingRoutes(app, opts) {
|
|
|
58726
59305
|
discoveryDate
|
|
58727
59306
|
}).run();
|
|
58728
59307
|
app.db.update(runs).set({ status: RunStatuses.completed, finishedAt: now }).where(eq61(runs.id, runId)).run();
|
|
59308
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
|
|
58729
59309
|
return {
|
|
58730
59310
|
id,
|
|
58731
59311
|
url,
|
|
@@ -58742,6 +59322,12 @@ async function bingRoutes(app, opts) {
|
|
|
58742
59322
|
const msg = describeError(e);
|
|
58743
59323
|
bingLog("error", "inspect-url.failed", { domain: project.canonicalDomain, url, error: msg });
|
|
58744
59324
|
app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq61(runs.id, runId)).run();
|
|
59325
|
+
const failure = classifyOutcomeError(e);
|
|
59326
|
+
outcome2.report({
|
|
59327
|
+
status: OutcomeStatuses.failed,
|
|
59328
|
+
...failure,
|
|
59329
|
+
...e instanceof BingApiError && e.isThrottle ? { reasonCode: OutcomeReasonCodes.RATE_LIMITED } : {}
|
|
59330
|
+
});
|
|
58745
59331
|
throw e;
|
|
58746
59332
|
}
|
|
58747
59333
|
});
|
|
@@ -58893,35 +59479,48 @@ async function cdpRoutes(app, opts) {
|
|
|
58893
59479
|
const stream = fs.createReadStream(fullPath);
|
|
58894
59480
|
return reply.type("image/png").send(stream);
|
|
58895
59481
|
});
|
|
58896
|
-
app.put("/settings/cdp", async (request, reply) => {
|
|
59482
|
+
app.put("/settings/cdp", connectionRoute(app, () => ({ integration: "cdp", action: "connect" }), async (request, reply, attempt) => {
|
|
58897
59483
|
requireScope(request, SETTINGS_WRITE_SCOPE);
|
|
58898
59484
|
if (!opts.onCdpConfigure) {
|
|
58899
59485
|
const err = notImplemented("CDP configuration not supported in this deployment");
|
|
59486
|
+
attempt.failed(err);
|
|
58900
59487
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58901
59488
|
}
|
|
58902
59489
|
const { host, port = 9222 } = request.body;
|
|
58903
59490
|
if (!host || typeof host !== "string") {
|
|
58904
59491
|
const err = validationError("host is required");
|
|
59492
|
+
attempt.failed(err);
|
|
58905
59493
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58906
59494
|
}
|
|
58907
59495
|
const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
|
|
58908
59496
|
if (!ALLOWED_HOSTS.includes(host)) {
|
|
58909
59497
|
const err = validationError("host must be localhost, 127.0.0.1, or ::1");
|
|
59498
|
+
attempt.failed(err, "BLOCKED_UNSAFE_URL");
|
|
58910
59499
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58911
59500
|
}
|
|
58912
59501
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
58913
59502
|
const err = validationError("port must be an integer between 1 and 65535");
|
|
59503
|
+
attempt.failed(err);
|
|
58914
59504
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58915
59505
|
}
|
|
58916
59506
|
await opts.onCdpConfigure(host, port);
|
|
58917
59507
|
return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
|
|
58918
|
-
});
|
|
59508
|
+
}));
|
|
58919
59509
|
app.get("/cdp/status", async (_request, reply) => {
|
|
58920
59510
|
if (!opts.getCdpStatus) {
|
|
58921
59511
|
const err = notImplemented("CDP not configured");
|
|
58922
59512
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
58923
59513
|
}
|
|
58924
|
-
const
|
|
59514
|
+
const attempt = startConnectionAttempt(app, { integration: "cdp", action: "test" });
|
|
59515
|
+
let status;
|
|
59516
|
+
try {
|
|
59517
|
+
status = await opts.getCdpStatus();
|
|
59518
|
+
} catch (err) {
|
|
59519
|
+
attempt.failed(err);
|
|
59520
|
+
throw err;
|
|
59521
|
+
}
|
|
59522
|
+
if (status.connected) attempt.succeeded();
|
|
59523
|
+
else if (status.endpoint) attempt.failed(void 0, "NETWORK");
|
|
58925
59524
|
return reply.send(status);
|
|
58926
59525
|
});
|
|
58927
59526
|
app.post("/cdp/screenshot", async (request, reply) => {
|
|
@@ -59546,6 +60145,17 @@ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
|
|
|
59546
60145
|
});
|
|
59547
60146
|
return { accessToken, propertyId: oauthConn.propertyId };
|
|
59548
60147
|
}
|
|
60148
|
+
function ga4SyncFailure(err, credentialsResolved, opts, project) {
|
|
60149
|
+
const classified = classifyOutcomeError(err);
|
|
60150
|
+
if (credentialsResolved) {
|
|
60151
|
+
return err instanceof GA4ApiError && err.status === 404 ? { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND } : classified;
|
|
60152
|
+
}
|
|
60153
|
+
if (!(err instanceof AppError)) return credentialFailure(err);
|
|
60154
|
+
const oauth = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
60155
|
+
const serviceAccount = opts.ga4CredentialStore?.getConnection(project.name);
|
|
60156
|
+
const unselected = Boolean(oauth?.refreshToken && !oauth.propertyId) || Boolean(serviceAccount?.clientEmail && !serviceAccount.propertyId);
|
|
60157
|
+
return { ...classified, reasonCode: unselected ? OutcomeReasonCodes.PROPERTY_NOT_FOUND : OutcomeReasonCodes.NOT_CONNECTED };
|
|
60158
|
+
}
|
|
59549
60159
|
function requireGa4Connection(opts, projectName, canonicalDomain) {
|
|
59550
60160
|
const saConn = opts.ga4CredentialStore?.getConnection(projectName);
|
|
59551
60161
|
const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
|
|
@@ -59554,7 +60164,10 @@ function requireGa4Connection(opts, projectName, canonicalDomain) {
|
|
|
59554
60164
|
}
|
|
59555
60165
|
}
|
|
59556
60166
|
async function ga4Routes(app, opts) {
|
|
59557
|
-
app.post("/projects/:name/ga/connect",
|
|
60167
|
+
app.post("/projects/:name/ga/connect", connectionRoute(app, (request) => {
|
|
60168
|
+
const body = request.body;
|
|
60169
|
+
return { integration: "ga4", action: typeof body?.keyJson === "string" && body.keyJson ? "connect" : "select" };
|
|
60170
|
+
}, async (request, _reply, attempt) => {
|
|
59558
60171
|
const project = resolveProject(app.db, request.params.name);
|
|
59559
60172
|
const { propertyId, keyJson } = request.body ?? {};
|
|
59560
60173
|
if (!propertyId || typeof propertyId !== "string") {
|
|
@@ -59581,10 +60194,12 @@ async function ga4Routes(app, opts) {
|
|
|
59581
60194
|
} catch (e) {
|
|
59582
60195
|
const msg = describeError(e);
|
|
59583
60196
|
gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
|
|
60197
|
+
attempt.failed(e);
|
|
59584
60198
|
throw validationError(`Failed to verify GA4 credentials: ${msg}`);
|
|
59585
60199
|
}
|
|
59586
60200
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
59587
60201
|
const existing = opts.ga4CredentialStore.getConnection(project.name);
|
|
60202
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
59588
60203
|
const previousPropertyId2 = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
|
|
59589
60204
|
opts.ga4CredentialStore.upsertConnection({
|
|
59590
60205
|
projectName: project.name,
|
|
@@ -59616,6 +60231,7 @@ async function ga4Routes(app, opts) {
|
|
|
59616
60231
|
}
|
|
59617
60232
|
const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
|
|
59618
60233
|
if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
|
|
60234
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
59619
60235
|
throw validationError(
|
|
59620
60236
|
'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
|
|
59621
60237
|
);
|
|
@@ -59631,6 +60247,7 @@ async function ga4Routes(app, opts) {
|
|
|
59631
60247
|
} catch (e) {
|
|
59632
60248
|
const msg = describeError(e);
|
|
59633
60249
|
gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
|
|
60250
|
+
attempt.failed(e);
|
|
59634
60251
|
throw validationError(`Failed to verify GA4 access: ${msg}`);
|
|
59635
60252
|
}
|
|
59636
60253
|
const previousPropertyId = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
|
|
@@ -59651,12 +60268,13 @@ async function ga4Routes(app, opts) {
|
|
|
59651
60268
|
entityId: propertyId
|
|
59652
60269
|
});
|
|
59653
60270
|
return { connected: true, propertyId, authMethod: "oauth" };
|
|
59654
|
-
});
|
|
59655
|
-
app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
|
|
60271
|
+
}));
|
|
60272
|
+
app.delete("/projects/:name/ga/disconnect", connectionRoute(app, () => ({ integration: "ga4", action: "disconnect" }), async (request, reply, attempt) => {
|
|
59656
60273
|
const project = resolveProject(app.db, request.params.name);
|
|
59657
60274
|
const saConn = opts.ga4CredentialStore?.getConnection(project.name);
|
|
59658
60275
|
const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
|
|
59659
60276
|
if (!saConn && !oauthConn) {
|
|
60277
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
59660
60278
|
throw notFound("GA4 connection", project.name);
|
|
59661
60279
|
}
|
|
59662
60280
|
app.db.delete(gaTrafficSnapshots).where(eq64(gaTrafficSnapshots.projectId, project.id)).run();
|
|
@@ -59676,7 +60294,7 @@ async function ga4Routes(app, opts) {
|
|
|
59676
60294
|
entityId: propertyId
|
|
59677
60295
|
});
|
|
59678
60296
|
return reply.status(204).send();
|
|
59679
|
-
});
|
|
60297
|
+
}));
|
|
59680
60298
|
app.get("/projects/:name/ga/properties", async (request) => {
|
|
59681
60299
|
assertNotProjectScoped(request, "listing GA4 properties");
|
|
59682
60300
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -59750,8 +60368,12 @@ async function ga4Routes(app, opts) {
|
|
|
59750
60368
|
startedAt,
|
|
59751
60369
|
createdAt: startedAt
|
|
59752
60370
|
}).run();
|
|
60371
|
+
const outcome2 = startRouteOutcome(app, FeatureNames.ga4, "sync");
|
|
60372
|
+
let credentialsResolved = false;
|
|
60373
|
+
let componentFailure;
|
|
59753
60374
|
try {
|
|
59754
60375
|
const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
|
|
60376
|
+
credentialsResolved = true;
|
|
59755
60377
|
let rows = [];
|
|
59756
60378
|
let aiReferrals = [];
|
|
59757
60379
|
let socialReferrals = [];
|
|
@@ -59924,6 +60546,7 @@ async function ga4Routes(app, opts) {
|
|
|
59924
60546
|
const [acquisitionSettled, leadsSettled, searchLandingSettled] = await measurementFetch;
|
|
59925
60547
|
const measurementNow = (/* @__PURE__ */ new Date()).toISOString();
|
|
59926
60548
|
const recordMeasurementFailure = (component, reason) => {
|
|
60549
|
+
componentFailure ??= { error: reason };
|
|
59927
60550
|
const message = describeError(reason);
|
|
59928
60551
|
gaLog("warn", "measurement.component-failed", {
|
|
59929
60552
|
projectId: project.id,
|
|
@@ -59989,6 +60612,7 @@ async function ga4Routes(app, opts) {
|
|
|
59989
60612
|
}
|
|
59990
60613
|
})();
|
|
59991
60614
|
const recordSearchLandingFailure = (status, reason) => {
|
|
60615
|
+
if (status === "error") componentFailure ??= { error: reason };
|
|
59992
60616
|
const message = describeError(reason);
|
|
59993
60617
|
gaLog("warn", "search-landing.component-failed", {
|
|
59994
60618
|
projectId: project.id,
|
|
@@ -60047,6 +60671,14 @@ async function ga4Routes(app, opts) {
|
|
|
60047
60671
|
totalUsers: summary3.totalUsers,
|
|
60048
60672
|
...only ? { only } : {}
|
|
60049
60673
|
});
|
|
60674
|
+
const counts2 = { rows: rows.length + dailyTotals.length + aiReferrals.length + socialReferrals.length };
|
|
60675
|
+
if (componentFailure) {
|
|
60676
|
+
outcome2.report({ status: OutcomeStatuses.partial, ...classifyOutcomeError(componentFailure.error), counts: counts2 });
|
|
60677
|
+
} else if (rows.length === 0 && dailyTotals.length === 0) {
|
|
60678
|
+
outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA, counts: counts2 });
|
|
60679
|
+
} else {
|
|
60680
|
+
outcome2.report({ status: OutcomeStatuses.succeeded, counts: counts2 });
|
|
60681
|
+
}
|
|
60050
60682
|
return {
|
|
60051
60683
|
synced: true,
|
|
60052
60684
|
rowCount: rows.length,
|
|
@@ -60067,6 +60699,7 @@ async function ga4Routes(app, opts) {
|
|
|
60067
60699
|
const msg = describeError(e);
|
|
60068
60700
|
gaLog("error", "sync.fetch-failed", { projectId: project.id, runId, error: msg });
|
|
60069
60701
|
app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq64(runs.id, runId)).run();
|
|
60702
|
+
outcome2.report({ status: OutcomeStatuses.failed, ...ga4SyncFailure(e, credentialsResolved, opts, project) });
|
|
60070
60703
|
throw e;
|
|
60071
60704
|
}
|
|
60072
60705
|
});
|
|
@@ -61706,16 +62339,43 @@ function toAppError(error) {
|
|
|
61706
62339
|
return providerError(error.message, { statusCode: error.statusCode });
|
|
61707
62340
|
}
|
|
61708
62341
|
}
|
|
61709
|
-
|
|
62342
|
+
function wordpressFailureReason(error) {
|
|
62343
|
+
if (error instanceof EgressRefusedError) return "BLOCKED_UNSAFE_URL";
|
|
62344
|
+
if (error instanceof EgressFailedError) return "NETWORK";
|
|
62345
|
+
return void 0;
|
|
62346
|
+
}
|
|
62347
|
+
async function withWordpressErrorHandling(handler, attempt) {
|
|
61710
62348
|
try {
|
|
61711
62349
|
return await handler();
|
|
61712
62350
|
} catch (error) {
|
|
62351
|
+
attempt?.failed(error, wordpressFailureReason(error));
|
|
61713
62352
|
if (error instanceof WordpressApiError) throw toAppError(error);
|
|
61714
62353
|
if (error instanceof EgressRefusedError && !error.redirected) throw validationError(error.message);
|
|
61715
62354
|
if (error instanceof EgressRefusedError || error instanceof EgressFailedError) throw providerError(error.message);
|
|
61716
62355
|
throw error;
|
|
61717
62356
|
}
|
|
61718
62357
|
}
|
|
62358
|
+
var MANUAL_ASSIST_OUTCOME = { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.UNSUPPORTED };
|
|
62359
|
+
function metaWriteOutcome(results) {
|
|
62360
|
+
const applied = results.filter((r) => r.status === "applied").length;
|
|
62361
|
+
const manual = results.filter((r) => r.status === "manual").length;
|
|
62362
|
+
const errored = results.filter((r) => r.status === "skipped").length;
|
|
62363
|
+
const counts2 = { items: applied, skipped: manual, failures: errored };
|
|
62364
|
+
if (manual === 0 && errored === 0) return { status: OutcomeStatuses.succeeded, counts: counts2 };
|
|
62365
|
+
const reasonCode = manual > 0 ? OutcomeReasonCodes.UNSUPPORTED : OutcomeReasonCodes.UNKNOWN;
|
|
62366
|
+
if (applied > 0) return { status: OutcomeStatuses.partial, reasonCode, counts: counts2 };
|
|
62367
|
+
return { status: errored > 0 ? OutcomeStatuses.failed : OutcomeStatuses.skipped, reasonCode, counts: counts2 };
|
|
62368
|
+
}
|
|
62369
|
+
function schemaDeployOutcome(results) {
|
|
62370
|
+
const deployed = results.filter((r) => r.status === "deployed").length;
|
|
62371
|
+
const stripped = results.filter((r) => r.status === "stripped").length;
|
|
62372
|
+
const missing = results.filter((r) => r.status === "skipped").length;
|
|
62373
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
62374
|
+
const counts2 = { items: deployed, skipped: stripped + missing, failures: failed };
|
|
62375
|
+
if (deployed === results.length) return { status: OutcomeStatuses.succeeded, counts: counts2 };
|
|
62376
|
+
const reasonCode = stripped > 0 ? OutcomeReasonCodes.PERMISSION_MISSING : failed > 0 ? OutcomeReasonCodes.UNKNOWN : OutcomeReasonCodes.NOT_FOUND;
|
|
62377
|
+
return { status: deployed > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, reasonCode, counts: counts2 };
|
|
62378
|
+
}
|
|
61719
62379
|
async function wordpressRoutes(app, opts) {
|
|
61720
62380
|
const allowLoopback = opts.allowLoopbackWebhooks === true;
|
|
61721
62381
|
function requireStore() {
|
|
@@ -61726,20 +62386,22 @@ async function wordpressRoutes(app, opts) {
|
|
|
61726
62386
|
function guarded(connection) {
|
|
61727
62387
|
return { ...connection, fetchImpl: egressFetch };
|
|
61728
62388
|
}
|
|
61729
|
-
async function assertWordpressUrlAllowed(rawUrl, field) {
|
|
62389
|
+
async function assertWordpressUrlAllowed(rawUrl, field, attempt) {
|
|
61730
62390
|
const check2 = await resolveWebhookTarget(rawUrl, { allowLoopback });
|
|
61731
62391
|
if (!check2.ok) {
|
|
62392
|
+
attempt?.failed(void 0, webhookTargetRefusalReason(check2));
|
|
61732
62393
|
throw validationError(`${field} ${check2.message.replace(/^"url" /, "")}`);
|
|
61733
62394
|
}
|
|
61734
62395
|
}
|
|
61735
|
-
function requireConnection(store, projectName) {
|
|
62396
|
+
function requireConnection(store, projectName, settle) {
|
|
61736
62397
|
const connection = store.getConnection(projectName);
|
|
61737
62398
|
if (!connection) {
|
|
62399
|
+
settle?.({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED });
|
|
61738
62400
|
throw validationError(`No WordPress connection found for project "${projectName}". Run "canonry wordpress connect ${projectName}" first.`);
|
|
61739
62401
|
}
|
|
61740
62402
|
return guarded(connection);
|
|
61741
62403
|
}
|
|
61742
|
-
app.post("/projects/:name/wordpress/connect", async (request) => {
|
|
62404
|
+
app.post("/projects/:name/wordpress/connect", connectionRoute(app, () => ({ integration: "wordpress", action: "connect" }), async (request, _reply, attempt) => {
|
|
61743
62405
|
return withWordpressErrorHandling(async () => {
|
|
61744
62406
|
const store = requireStore();
|
|
61745
62407
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -61751,10 +62413,11 @@ async function wordpressRoutes(app, opts) {
|
|
|
61751
62413
|
if (defaultEnv === "staging" && !stagingUrl) {
|
|
61752
62414
|
throw validationError('defaultEnv "staging" requires stagingUrl');
|
|
61753
62415
|
}
|
|
61754
|
-
await assertWordpressUrlAllowed(url, "url");
|
|
61755
|
-
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
|
|
62416
|
+
await assertWordpressUrlAllowed(url, "url", attempt);
|
|
62417
|
+
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
|
|
61756
62418
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
61757
62419
|
const existing = store.getConnection(project.name);
|
|
62420
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
61758
62421
|
const nextConnection = {
|
|
61759
62422
|
projectName: project.name,
|
|
61760
62423
|
url,
|
|
@@ -61784,13 +62447,14 @@ async function wordpressRoutes(app, opts) {
|
|
|
61784
62447
|
staging,
|
|
61785
62448
|
adminUrl: getWpStagingAdminUrl(connection.url)
|
|
61786
62449
|
};
|
|
61787
|
-
});
|
|
61788
|
-
});
|
|
61789
|
-
app.delete("/projects/:name/wordpress/disconnect", async (request, reply) => {
|
|
62450
|
+
}, attempt);
|
|
62451
|
+
}));
|
|
62452
|
+
app.delete("/projects/:name/wordpress/disconnect", connectionRoute(app, () => ({ integration: "wordpress", action: "disconnect" }), async (request, reply, attempt) => {
|
|
61790
62453
|
const store = requireStore();
|
|
61791
62454
|
const project = resolveProject(app.db, request.params.name);
|
|
61792
62455
|
const deleted = store.deleteConnection(project.name);
|
|
61793
62456
|
if (!deleted) {
|
|
62457
|
+
attempt.failed(void 0, "NOT_CONNECTED");
|
|
61794
62458
|
throw notFound("WordPress connection", project.name);
|
|
61795
62459
|
}
|
|
61796
62460
|
writeAuditLog(app.db, {
|
|
@@ -61801,7 +62465,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
61801
62465
|
entityId: project.name
|
|
61802
62466
|
});
|
|
61803
62467
|
return reply.status(204).send();
|
|
61804
|
-
});
|
|
62468
|
+
}));
|
|
61805
62469
|
app.get("/projects/:name/wordpress/status", async (request) => {
|
|
61806
62470
|
const project = resolveProject(app.db, request.params.name);
|
|
61807
62471
|
const stored = opts.wordpressConnectionStore?.getConnection(project.name);
|
|
@@ -61853,10 +62517,10 @@ async function wordpressRoutes(app, opts) {
|
|
|
61853
62517
|
});
|
|
61854
62518
|
});
|
|
61855
62519
|
app.post("/projects/:name/wordpress/pages", async (request) => {
|
|
61856
|
-
return withWordpressErrorHandling(async () => {
|
|
62520
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
61857
62521
|
const store = requireStore();
|
|
61858
62522
|
const project = resolveProject(app.db, request.params.name);
|
|
61859
|
-
const connection = requireConnection(store, project.name);
|
|
62523
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61860
62524
|
const { title, slug, content, status } = request.body ?? {};
|
|
61861
62525
|
const env = parseEnvInput(request.body?.env);
|
|
61862
62526
|
if (!title || !slug || !content) {
|
|
@@ -61870,14 +62534,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61870
62534
|
entityType: "wordpress_page",
|
|
61871
62535
|
entityId: created.slug
|
|
61872
62536
|
});
|
|
62537
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61873
62538
|
return created;
|
|
61874
|
-
});
|
|
62539
|
+
}, wordpressFailureReason));
|
|
61875
62540
|
});
|
|
61876
62541
|
app.put("/projects/:name/wordpress/page", async (request) => {
|
|
61877
|
-
return withWordpressErrorHandling(async () => {
|
|
62542
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
61878
62543
|
const store = requireStore();
|
|
61879
62544
|
const project = resolveProject(app.db, request.params.name);
|
|
61880
|
-
const connection = requireConnection(store, project.name);
|
|
62545
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61881
62546
|
const currentSlug = request.body?.currentSlug?.trim();
|
|
61882
62547
|
if (!currentSlug) {
|
|
61883
62548
|
throw validationError("currentSlug is required");
|
|
@@ -61896,14 +62561,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61896
62561
|
entityType: "wordpress_page",
|
|
61897
62562
|
entityId: currentSlug
|
|
61898
62563
|
});
|
|
62564
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61899
62565
|
return updated;
|
|
61900
|
-
});
|
|
62566
|
+
}, wordpressFailureReason));
|
|
61901
62567
|
});
|
|
61902
62568
|
app.post("/projects/:name/wordpress/page/meta", async (request) => {
|
|
61903
|
-
return withWordpressErrorHandling(async () => {
|
|
62569
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
|
|
61904
62570
|
const store = requireStore();
|
|
61905
62571
|
const project = resolveProject(app.db, request.params.name);
|
|
61906
|
-
const connection = requireConnection(store, project.name);
|
|
62572
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61907
62573
|
const slug = request.body?.slug?.trim();
|
|
61908
62574
|
if (!slug) {
|
|
61909
62575
|
throw validationError("slug is required");
|
|
@@ -61921,14 +62587,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
61921
62587
|
entityType: "wordpress_page",
|
|
61922
62588
|
entityId: slug
|
|
61923
62589
|
});
|
|
62590
|
+
settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
|
|
61924
62591
|
return updated;
|
|
61925
|
-
});
|
|
62592
|
+
}, wordpressFailureReason));
|
|
61926
62593
|
});
|
|
61927
62594
|
app.post("/projects/:name/wordpress/pages/meta/bulk", async (request) => {
|
|
61928
|
-
return withWordpressErrorHandling(async () => {
|
|
62595
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
|
|
61929
62596
|
const store = requireStore();
|
|
61930
62597
|
const project = resolveProject(app.db, request.params.name);
|
|
61931
|
-
const connection = requireConnection(store, project.name);
|
|
62598
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61932
62599
|
const entries = request.body?.entries;
|
|
61933
62600
|
if (!Array.isArray(entries) || entries.length === 0) {
|
|
61934
62601
|
throw validationError("entries array is required and must not be empty");
|
|
@@ -61950,8 +62617,9 @@ async function wordpressRoutes(app, opts) {
|
|
|
61950
62617
|
entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
|
|
61951
62618
|
});
|
|
61952
62619
|
}
|
|
62620
|
+
settle(metaWriteOutcome(result.results));
|
|
61953
62621
|
return result;
|
|
61954
|
-
});
|
|
62622
|
+
}, wordpressFailureReason));
|
|
61955
62623
|
});
|
|
61956
62624
|
app.get("/projects/:name/wordpress/schema", async (request) => {
|
|
61957
62625
|
return withWordpressErrorHandling(async () => {
|
|
@@ -61967,31 +62635,35 @@ async function wordpressRoutes(app, opts) {
|
|
|
61967
62635
|
});
|
|
61968
62636
|
});
|
|
61969
62637
|
app.post("/projects/:name/wordpress/schema/manual", async (request) => {
|
|
61970
|
-
return withWordpressErrorHandling(async () => {
|
|
62638
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
|
|
61971
62639
|
const store = requireStore();
|
|
61972
62640
|
const project = resolveProject(app.db, request.params.name);
|
|
61973
|
-
const connection = requireConnection(store, project.name);
|
|
62641
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61974
62642
|
const slug = request.body?.slug?.trim();
|
|
61975
62643
|
const json = request.body?.json;
|
|
61976
62644
|
if (!slug || !json) {
|
|
61977
62645
|
throw validationError("slug and json are required");
|
|
61978
62646
|
}
|
|
61979
62647
|
const env = parseEnvInput(request.body?.env);
|
|
61980
|
-
|
|
61981
|
-
|
|
62648
|
+
const manual = await buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
|
|
62649
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62650
|
+
return manual;
|
|
62651
|
+
}, wordpressFailureReason));
|
|
61982
62652
|
});
|
|
61983
62653
|
app.post("/projects/:name/wordpress/schema/deploy", async (request) => {
|
|
61984
|
-
return withWordpressErrorHandling(async () => {
|
|
62654
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
|
|
61985
62655
|
const store = requireStore();
|
|
61986
62656
|
const project = resolveProject(app.db, request.params.name);
|
|
61987
|
-
const connection = requireConnection(store, project.name);
|
|
62657
|
+
const connection = requireConnection(store, project.name, settle);
|
|
61988
62658
|
const profile = request.body?.profile;
|
|
61989
62659
|
if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
|
|
61990
62660
|
throw validationError("profile with business.name and non-empty pages is required");
|
|
61991
62661
|
}
|
|
61992
62662
|
const env = parseEnvInput(request.body?.env);
|
|
61993
|
-
|
|
61994
|
-
|
|
62663
|
+
const deployed = await deploySchemaFromProfile(connection, profile, env);
|
|
62664
|
+
settle(schemaDeployOutcome(deployed.results));
|
|
62665
|
+
return deployed;
|
|
62666
|
+
}, wordpressFailureReason));
|
|
61995
62667
|
});
|
|
61996
62668
|
app.get("/projects/:name/wordpress/schema/status", async (request) => {
|
|
61997
62669
|
return withWordpressErrorHandling(async () => {
|
|
@@ -62012,17 +62684,19 @@ async function wordpressRoutes(app, opts) {
|
|
|
62012
62684
|
});
|
|
62013
62685
|
});
|
|
62014
62686
|
app.post("/projects/:name/wordpress/llms-txt/manual", async (request) => {
|
|
62015
|
-
return withWordpressErrorHandling(async () => {
|
|
62687
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "llms_txt" }, async (settle) => {
|
|
62016
62688
|
const store = requireStore();
|
|
62017
62689
|
const project = resolveProject(app.db, request.params.name);
|
|
62018
|
-
const connection = requireConnection(store, project.name);
|
|
62690
|
+
const connection = requireConnection(store, project.name, settle);
|
|
62019
62691
|
const content = request.body?.content;
|
|
62020
62692
|
if (!content) {
|
|
62021
62693
|
throw validationError("content is required");
|
|
62022
62694
|
}
|
|
62023
62695
|
const env = parseEnvInput(request.body?.env);
|
|
62024
|
-
|
|
62025
|
-
|
|
62696
|
+
const manual = await buildManualLlmsTxtUpdate(connection, content, env);
|
|
62697
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62698
|
+
return manual;
|
|
62699
|
+
}, wordpressFailureReason));
|
|
62026
62700
|
});
|
|
62027
62701
|
app.get("/projects/:name/wordpress/audit", async (request) => {
|
|
62028
62702
|
return withWordpressErrorHandling(async () => {
|
|
@@ -62060,17 +62734,20 @@ async function wordpressRoutes(app, opts) {
|
|
|
62060
62734
|
});
|
|
62061
62735
|
});
|
|
62062
62736
|
app.post("/projects/:name/wordpress/staging/push", async (request) => {
|
|
62063
|
-
return withWordpressErrorHandling(async () => {
|
|
62737
|
+
return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
|
|
62064
62738
|
const store = requireStore();
|
|
62065
62739
|
const project = resolveProject(app.db, request.params.name);
|
|
62066
|
-
const connection = requireConnection(store, project.name);
|
|
62740
|
+
const connection = requireConnection(store, project.name, settle);
|
|
62067
62741
|
if (!connection.stagingUrl) {
|
|
62068
62742
|
throw validationError("No staging URL configured for this project. Reconnect with --staging-url before using staging push.");
|
|
62069
62743
|
}
|
|
62070
|
-
|
|
62071
|
-
|
|
62744
|
+
const manual = await buildManualStagingPush(connection);
|
|
62745
|
+
settle(MANUAL_ASSIST_OUTCOME);
|
|
62746
|
+
return manual;
|
|
62747
|
+
}, wordpressFailureReason));
|
|
62072
62748
|
});
|
|
62073
62749
|
app.post("/projects/:name/wordpress/onboard", async (request) => {
|
|
62750
|
+
const attempt = startConnectionAttempt(app, { integration: "wordpress", action: "connect" });
|
|
62074
62751
|
return withWordpressErrorHandling(async () => {
|
|
62075
62752
|
const store = requireStore();
|
|
62076
62753
|
const project = resolveProject(app.db, request.params.name);
|
|
@@ -62082,14 +62759,15 @@ async function wordpressRoutes(app, opts) {
|
|
|
62082
62759
|
if (defaultEnv === "staging" && !stagingUrl) {
|
|
62083
62760
|
throw validationError('defaultEnv "staging" requires stagingUrl');
|
|
62084
62761
|
}
|
|
62085
|
-
await assertWordpressUrlAllowed(url, "url");
|
|
62086
|
-
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
|
|
62762
|
+
await assertWordpressUrlAllowed(url, "url", attempt);
|
|
62763
|
+
if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
|
|
62087
62764
|
const steps = [];
|
|
62088
62765
|
let connection = null;
|
|
62089
62766
|
let pageUrls = [];
|
|
62090
62767
|
try {
|
|
62091
62768
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
62092
62769
|
const existing = store.getConnection(project.name);
|
|
62770
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
62093
62771
|
const nextConnection = {
|
|
62094
62772
|
projectName: project.name,
|
|
62095
62773
|
url,
|
|
@@ -62110,7 +62788,9 @@ async function wordpressRoutes(app, opts) {
|
|
|
62110
62788
|
entityId: project.name
|
|
62111
62789
|
});
|
|
62112
62790
|
steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
|
|
62791
|
+
attempt.succeeded();
|
|
62113
62792
|
} catch (err) {
|
|
62793
|
+
attempt.failed(err, wordpressFailureReason(err));
|
|
62114
62794
|
const msg = describeError(err);
|
|
62115
62795
|
steps.push({ name: "connect", status: "failed", error: msg });
|
|
62116
62796
|
return { projectName: project.name, steps };
|
|
@@ -62131,6 +62811,11 @@ async function wordpressRoutes(app, opts) {
|
|
|
62131
62811
|
steps.push({ name: "audit", status: "failed", error: msg });
|
|
62132
62812
|
return { projectName: project.name, steps };
|
|
62133
62813
|
}
|
|
62814
|
+
const metaStartedAt = Date.now();
|
|
62815
|
+
const reportMetaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, {
|
|
62816
|
+
...outcome2,
|
|
62817
|
+
durationBucket: bucketDuration(Date.now() - metaStartedAt)
|
|
62818
|
+
});
|
|
62134
62819
|
try {
|
|
62135
62820
|
const metaEntries = [];
|
|
62136
62821
|
for (const issue of auditIssues) {
|
|
@@ -62155,8 +62840,10 @@ async function wordpressRoutes(app, opts) {
|
|
|
62155
62840
|
}
|
|
62156
62841
|
if (metaEntries.length === 0) {
|
|
62157
62842
|
steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
|
|
62843
|
+
reportMetaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
|
|
62158
62844
|
} else {
|
|
62159
62845
|
const result = await bulkSetSeoMeta(connection, metaEntries);
|
|
62846
|
+
reportMetaStep(metaWriteOutcome(result.results));
|
|
62160
62847
|
const applied = result.results.filter((r) => r.status === "applied").length;
|
|
62161
62848
|
const manual = result.results.filter((r) => r.status === "manual").length;
|
|
62162
62849
|
const skipped4 = result.results.filter((r) => r.status === "skipped").length;
|
|
@@ -62169,6 +62856,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62169
62856
|
} catch (err) {
|
|
62170
62857
|
const msg = describeError(err);
|
|
62171
62858
|
steps.push({ name: "set-meta", status: "failed", error: msg });
|
|
62859
|
+
reportMetaStep(failedOutcome(err, wordpressFailureReason(err)));
|
|
62172
62860
|
return { projectName: project.name, steps };
|
|
62173
62861
|
}
|
|
62174
62862
|
if (skipSchema || !profile) {
|
|
@@ -62178,11 +62866,18 @@ async function wordpressRoutes(app, opts) {
|
|
|
62178
62866
|
summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
|
|
62179
62867
|
});
|
|
62180
62868
|
} else {
|
|
62869
|
+
const schemaStartedAt = Date.now();
|
|
62870
|
+
const reportSchemaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, {
|
|
62871
|
+
...outcome2,
|
|
62872
|
+
durationBucket: bucketDuration(Date.now() - schemaStartedAt)
|
|
62873
|
+
});
|
|
62181
62874
|
try {
|
|
62182
62875
|
if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
|
|
62183
62876
|
steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
|
|
62877
|
+
reportSchemaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.VALIDATION });
|
|
62184
62878
|
} else {
|
|
62185
62879
|
const result = await deploySchemaFromProfile(connection, profile);
|
|
62880
|
+
reportSchemaStep(schemaDeployOutcome(result.results));
|
|
62186
62881
|
const deployed = result.results.filter((r) => r.status === "deployed").length;
|
|
62187
62882
|
const stripped = result.results.filter((r) => r.status === "stripped").length;
|
|
62188
62883
|
const skipped4 = result.results.filter((r) => r.status === "skipped").length;
|
|
@@ -62195,6 +62890,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62195
62890
|
} catch (err) {
|
|
62196
62891
|
const msg = describeError(err);
|
|
62197
62892
|
steps.push({ name: "schema-deploy", status: "failed", error: msg });
|
|
62893
|
+
reportSchemaStep(failedOutcome(err, wordpressFailureReason(err)));
|
|
62198
62894
|
return { projectName: project.name, steps };
|
|
62199
62895
|
}
|
|
62200
62896
|
}
|
|
@@ -62262,7 +62958,7 @@ async function wordpressRoutes(app, opts) {
|
|
|
62262
62958
|
entityId: project.name
|
|
62263
62959
|
});
|
|
62264
62960
|
return { projectName: project.name, steps };
|
|
62265
|
-
});
|
|
62961
|
+
}, attempt);
|
|
62266
62962
|
});
|
|
62267
62963
|
}
|
|
62268
62964
|
|
|
@@ -62854,13 +63550,14 @@ async function backlinksRoutes(app, opts) {
|
|
|
62854
63550
|
}
|
|
62855
63551
|
return reply.send(opts.getBacklinksStatus());
|
|
62856
63552
|
});
|
|
62857
|
-
app.post("/backlinks/install", async (_request, reply) => {
|
|
63553
|
+
app.post("/backlinks/install", async (_request, reply) => withFeatureOutcome(app, { feature: "backlinks", operation: "install" }, async (settle) => {
|
|
62858
63554
|
if (!opts.onInstallBacklinks) {
|
|
62859
63555
|
throw missingDependency(BACKLINKS_UNSUPPORTED_MESSAGE);
|
|
62860
63556
|
}
|
|
62861
63557
|
const result = await opts.onInstallBacklinks();
|
|
63558
|
+
if (result.alreadyPresent) settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.ALREADY_CONNECTED });
|
|
62862
63559
|
return reply.status(200).send(result);
|
|
62863
|
-
});
|
|
63560
|
+
}, (err) => err instanceof AppError && err.code === "MISSING_DEPENDENCY" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
|
|
62864
63561
|
app.post("/backlinks/syncs", async (request, reply) => {
|
|
62865
63562
|
let release = request.body?.release;
|
|
62866
63563
|
if (!release) {
|
|
@@ -79023,6 +79720,7 @@ async function deliverViaDirectPush(env, batch) {
|
|
|
79023
79720
|
method: 'POST',
|
|
79024
79721
|
headers: {
|
|
79025
79722
|
'content-type': 'application/json',
|
|
79723
|
+
'user-agent': 'canonry-cloudflare-worker/' + batch.workerVersion,
|
|
79026
79724
|
'Authorization': 'Bearer ' + bearerToken,
|
|
79027
79725
|
'X-Canonry-Timestamp': timestamp,
|
|
79028
79726
|
'X-Canonry-Signature': signature,
|
|
@@ -80481,6 +81179,12 @@ async function runBackfillTask(options) {
|
|
|
80481
81179
|
pullForBackfill,
|
|
80482
81180
|
pullErrorPrefix
|
|
80483
81181
|
} = options;
|
|
81182
|
+
const backfillStartedAt = Date.now();
|
|
81183
|
+
const target = { feature: "server_traffic", operation: "backfill", attribution: currentOutcomeAttribution() };
|
|
81184
|
+
const reportBackfill = (outcome2) => reportFeatureOutcome(app, target, {
|
|
81185
|
+
...outcome2,
|
|
81186
|
+
durationBucket: bucketDuration(Date.now() - backfillStartedAt)
|
|
81187
|
+
});
|
|
80484
81188
|
const markFailed = (msg, preserveSourceState = false) => {
|
|
80485
81189
|
const failedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
80486
81190
|
try {
|
|
@@ -80496,6 +81200,7 @@ async function runBackfillTask(options) {
|
|
|
80496
81200
|
};
|
|
80497
81201
|
if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
|
|
80498
81202
|
markFailed("Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span.");
|
|
81203
|
+
reportBackfill({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.UNSUPPORTED });
|
|
80499
81204
|
return;
|
|
80500
81205
|
}
|
|
80501
81206
|
let allEvents;
|
|
@@ -80506,12 +81211,14 @@ async function runBackfillTask(options) {
|
|
|
80506
81211
|
`${pullErrorPrefix}: ${describeError(e)}`,
|
|
80507
81212
|
e instanceof VercelRetentionClampError
|
|
80508
81213
|
);
|
|
81214
|
+
reportBackfill(failedOutcome(e, e instanceof VercelRetentionClampError ? OutcomeReasonCodes.NO_DATA : void 0));
|
|
80509
81215
|
return;
|
|
80510
81216
|
}
|
|
80511
81217
|
if (allEvents.length === 0) {
|
|
80512
81218
|
const finishedAt2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
81219
|
+
let emptyOutcome;
|
|
80513
81220
|
try {
|
|
80514
|
-
app.db.transaction((tx) => {
|
|
81221
|
+
const completed = app.db.transaction((tx) => {
|
|
80515
81222
|
const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
80516
81223
|
const stillAuthoritative = latestSource && isAuthoritativeTrafficSource(tx, latestSource) && isSameTrafficSourceGeneration(latestSource, sourceRow2);
|
|
80517
81224
|
if (stillAuthoritative) {
|
|
@@ -80522,9 +81229,13 @@ async function runBackfillTask(options) {
|
|
|
80522
81229
|
error: "Traffic source was deactivated or reconfigured during backfill",
|
|
80523
81230
|
finishedAt: finishedAt2
|
|
80524
81231
|
}).where(eq69(runs.id, runId)).run();
|
|
81232
|
+
return Boolean(stillAuthoritative);
|
|
80525
81233
|
});
|
|
80526
|
-
|
|
81234
|
+
emptyOutcome = completed ? { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA } : { status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
|
|
81235
|
+
} catch (e) {
|
|
81236
|
+
emptyOutcome = failedOutcome(e);
|
|
80527
81237
|
}
|
|
81238
|
+
reportBackfill(emptyOutcome);
|
|
80528
81239
|
return;
|
|
80529
81240
|
}
|
|
80530
81241
|
const report = buildTrafficProbeReport(allEvents, { sampleLimit: BACKFILL_SAMPLE_LIMIT });
|
|
@@ -80784,11 +81495,30 @@ async function runBackfillTask(options) {
|
|
|
80784
81495
|
tx.update(runs).set({ status: RunStatuses.completed, finishedAt }).where(eq69(runs.id, runId)).run();
|
|
80785
81496
|
return "committed";
|
|
80786
81497
|
});
|
|
81498
|
+
reportBackfill(commitOutcome === "committed" ? {
|
|
81499
|
+
status: OutcomeStatuses.succeeded,
|
|
81500
|
+
counts: {
|
|
81501
|
+
events: report.totals.normalizedEvents,
|
|
81502
|
+
crawlerHits: report.totals.crawlerHits,
|
|
81503
|
+
aiReferralHits: report.totals.aiReferralHits,
|
|
81504
|
+
aiUserFetchHits: report.totals.aiUserFetchHits
|
|
81505
|
+
}
|
|
81506
|
+
} : {
|
|
81507
|
+
status: OutcomeStatuses.failed,
|
|
81508
|
+
reasonCode: commitOutcome === "source-inactive" ? OutcomeReasonCodes.NOT_CONNECTED : OutcomeReasonCodes.UNSUPPORTED
|
|
81509
|
+
});
|
|
80787
81510
|
if (commitOutcome === "source-inactive" || commitOutcome === "wordpress-backfill-unsupported") return;
|
|
80788
81511
|
} catch (e) {
|
|
80789
81512
|
markFailed(`Backfill rollup write failed: ${describeError(e)}`);
|
|
81513
|
+
reportBackfill(failedOutcome(e));
|
|
80790
81514
|
}
|
|
80791
81515
|
}
|
|
81516
|
+
var TRAFFIC_SOURCE_INTEGRATIONS = {
|
|
81517
|
+
[TrafficSourceTypes.cloudflare]: "traffic_cloudflare",
|
|
81518
|
+
[TrafficSourceTypes["cloud-run"]]: "traffic_cloud_run",
|
|
81519
|
+
[TrafficSourceTypes.vercel]: "traffic_vercel",
|
|
81520
|
+
[TrafficSourceTypes.wordpress]: "traffic_wordpress"
|
|
81521
|
+
};
|
|
80792
81522
|
async function trafficRoutes(app, opts) {
|
|
80793
81523
|
const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
|
|
80794
81524
|
const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
|
|
@@ -80820,9 +81550,10 @@ async function trafficRoutes(app, opts) {
|
|
|
80820
81550
|
app.addHook("onClose", async () => {
|
|
80821
81551
|
if (rawSampleRetentionTimer) clearInterval(rawSampleRetentionTimer);
|
|
80822
81552
|
});
|
|
80823
|
-
async function assertWordpressTargetAllowed(baseUrl) {
|
|
81553
|
+
async function assertWordpressTargetAllowed(baseUrl, attempt) {
|
|
80824
81554
|
const check2 = await resolveWebhookTarget(baseUrl, { allowLoopback });
|
|
80825
81555
|
if (!check2.ok) {
|
|
81556
|
+
attempt?.failed(void 0, webhookTargetRefusalReason(check2));
|
|
80826
81557
|
throw validationError(`WordPress baseUrl rejected: ${check2.message}`);
|
|
80827
81558
|
}
|
|
80828
81559
|
}
|
|
@@ -80877,7 +81608,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80877
81608
|
}
|
|
80878
81609
|
throw validationError(`Traffic source type "${source.sourceType}" cannot be activated`);
|
|
80879
81610
|
}
|
|
80880
|
-
app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
|
|
81611
|
+
app.post("/projects/:name/traffic/connect/cloud-run", connectionRoute(app, () => ({ integration: "traffic_cloud_run", action: "connect" }), async (request, _reply, attempt) => {
|
|
80881
81612
|
const project = resolveProject(app.db, request.params.name);
|
|
80882
81613
|
const body = request.body ?? {};
|
|
80883
81614
|
const { gcpProjectId, serviceName, location, displayName, keyJson } = body;
|
|
@@ -80903,6 +81634,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80903
81634
|
}
|
|
80904
81635
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
80905
81636
|
const existing = opts.cloudRunCredentialStore.getConnection(project.name);
|
|
81637
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
80906
81638
|
opts.cloudRunCredentialStore.upsertConnection({
|
|
80907
81639
|
projectName: project.name,
|
|
80908
81640
|
gcpProjectId,
|
|
@@ -80966,8 +81698,8 @@ async function trafficRoutes(app, opts) {
|
|
|
80966
81698
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
80967
81699
|
}
|
|
80968
81700
|
return rowToDto(sourceRow2);
|
|
80969
|
-
});
|
|
80970
|
-
app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
|
|
81701
|
+
}));
|
|
81702
|
+
app.post("/projects/:name/traffic/connect/wordpress", connectionRoute(app, () => ({ integration: "traffic_wordpress", action: "connect" }), async (request, _reply, attempt) => {
|
|
80971
81703
|
const project = resolveProject(app.db, request.params.name);
|
|
80972
81704
|
if (!opts.wordpressTrafficCredentialStore) {
|
|
80973
81705
|
throw validationError("WordPress traffic credential storage is not configured for this deployment");
|
|
@@ -80978,7 +81710,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80978
81710
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
80979
81711
|
}
|
|
80980
81712
|
const { baseUrl, username, applicationPassword, displayName } = parsed.data;
|
|
80981
|
-
await assertWordpressTargetAllowed(baseUrl);
|
|
81713
|
+
await assertWordpressTargetAllowed(baseUrl, attempt);
|
|
80982
81714
|
try {
|
|
80983
81715
|
await pullWordpressEvents({
|
|
80984
81716
|
baseUrl,
|
|
@@ -80989,6 +81721,7 @@ async function trafficRoutes(app, opts) {
|
|
|
80989
81721
|
fetchImpl: wordpressTrafficFetch
|
|
80990
81722
|
});
|
|
80991
81723
|
} catch (e) {
|
|
81724
|
+
attempt.failed(e, e instanceof EgressRefusedError ? "BLOCKED_UNSAFE_URL" : e instanceof EgressFailedError ? "NETWORK" : void 0);
|
|
80992
81725
|
if (e instanceof WordpressTrafficApiError) {
|
|
80993
81726
|
throw providerError(
|
|
80994
81727
|
`WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
@@ -81000,6 +81733,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81000
81733
|
}
|
|
81001
81734
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81002
81735
|
const existing = credentialStore.getConnection(project.name);
|
|
81736
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
81003
81737
|
credentialStore.upsertConnection({
|
|
81004
81738
|
projectName: project.name,
|
|
81005
81739
|
baseUrl,
|
|
@@ -81063,8 +81797,8 @@ async function trafficRoutes(app, opts) {
|
|
|
81063
81797
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81064
81798
|
}
|
|
81065
81799
|
return rowToDto(sourceRow2);
|
|
81066
|
-
});
|
|
81067
|
-
app.post("/projects/:name/traffic/connect/vercel", async (request) => {
|
|
81800
|
+
}));
|
|
81801
|
+
app.post("/projects/:name/traffic/connect/vercel", connectionRoute(app, () => ({ integration: "traffic_vercel", action: "connect" }), async (request, _reply, attempt) => {
|
|
81068
81802
|
const project = resolveProject(app.db, request.params.name);
|
|
81069
81803
|
if (!opts.vercelTrafficCredentialStore) {
|
|
81070
81804
|
throw validationError("Vercel traffic credential storage is not configured for this deployment");
|
|
@@ -81088,6 +81822,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81088
81822
|
maxPages: 1
|
|
81089
81823
|
});
|
|
81090
81824
|
} catch (e) {
|
|
81825
|
+
attempt.failed(e);
|
|
81091
81826
|
if (e instanceof VercelLogsApiError) {
|
|
81092
81827
|
throw providerError(
|
|
81093
81828
|
`Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
|
|
@@ -81098,6 +81833,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81098
81833
|
}
|
|
81099
81834
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81100
81835
|
const existing = credentialStore.getConnection(project.name);
|
|
81836
|
+
if (existing) attempt.update({ action: "reauth" });
|
|
81101
81837
|
credentialStore.upsertConnection({
|
|
81102
81838
|
projectName: project.name,
|
|
81103
81839
|
projectId,
|
|
@@ -81175,8 +81911,8 @@ async function trafficRoutes(app, opts) {
|
|
|
81175
81911
|
opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81176
81912
|
}
|
|
81177
81913
|
return rowToDto(sourceRow2);
|
|
81178
|
-
});
|
|
81179
|
-
app.post("/projects/:name/traffic/connect/cloudflare", async (request) => {
|
|
81914
|
+
}));
|
|
81915
|
+
app.post("/projects/:name/traffic/connect/cloudflare", connectionRoute(app, () => ({ integration: "traffic_cloudflare", action: "connect" }), async (request, _reply, attempt) => {
|
|
81180
81916
|
const project = resolveProject(app.db, request.params.name);
|
|
81181
81917
|
if (!opts.cloudflareTrafficCredentialStore) {
|
|
81182
81918
|
throw validationError("Cloudflare traffic credential storage is not configured for this deployment");
|
|
@@ -81192,6 +81928,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81192
81928
|
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
81193
81929
|
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
81930
|
const sameModeSource = cloudflareSources.find((row) => parseQueuePullCloudflareSourceConfig(row.configJson));
|
|
81931
|
+
if (sameModeSource) attempt.update({ action: "reauth" });
|
|
81195
81932
|
const sourceId2 = sameModeSource?.id ?? crypto48.randomUUID();
|
|
81196
81933
|
const previousCredential2 = credentialStore.getConnectionBySourceId(sourceId2);
|
|
81197
81934
|
const workerVersion2 = CURRENT_CLOUDFLARE_WORKER_VERSION;
|
|
@@ -81327,6 +82064,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81327
82064
|
const workerRouteHost = resolveCloudflareWorkerRouteHost(project.canonicalDomain);
|
|
81328
82065
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81329
82066
|
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);
|
|
82067
|
+
if (activeSource) attempt.update({ action: "reauth" });
|
|
81330
82068
|
const sourceId = activeSource?.id ?? crypto48.randomUUID();
|
|
81331
82069
|
const parsedActiveConfig = activeSource ? parseDirectPushCloudflareSourceConfig(activeSource.configJson) : null;
|
|
81332
82070
|
if (activeSource && !parsedActiveConfig) {
|
|
@@ -81486,8 +82224,12 @@ async function trafficRoutes(app, opts) {
|
|
|
81486
82224
|
workerVersion,
|
|
81487
82225
|
instructions
|
|
81488
82226
|
};
|
|
81489
|
-
});
|
|
81490
|
-
app.post("/projects/:name/traffic/sources/:id/activate",
|
|
82227
|
+
}));
|
|
82228
|
+
app.post("/projects/:name/traffic/sources/:id/activate", connectionRoute(app, (request) => {
|
|
82229
|
+
const source = app.db.select({ sourceType: trafficSources.sourceType }).from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
|
|
82230
|
+
const integration = source ? TRAFFIC_SOURCE_INTEGRATIONS[source.sourceType] : void 0;
|
|
82231
|
+
return integration ? { integration, action: "select" } : void 0;
|
|
82232
|
+
}, async (request) => {
|
|
81491
82233
|
const project = resolveProject(app.db, request.params.name);
|
|
81492
82234
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
81493
82235
|
const result = app.db.transaction((tx) => {
|
|
@@ -81538,7 +82280,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81538
82280
|
opts.onScheduleUpdated?.(result.scheduleAction, project.id, SchedulableRunKinds["traffic-sync"]);
|
|
81539
82281
|
}
|
|
81540
82282
|
return rowToDto(result.source);
|
|
81541
|
-
});
|
|
82283
|
+
}));
|
|
81542
82284
|
app.post("/projects/:name/traffic/cloudflare/ingest", {
|
|
81543
82285
|
bodyLimit: CLOUDFLARE_INGEST_BODY_LIMIT,
|
|
81544
82286
|
preHandler: async (request, reply) => {
|
|
@@ -81567,6 +82309,22 @@ async function trafficRoutes(app, opts) {
|
|
|
81567
82309
|
}
|
|
81568
82310
|
}
|
|
81569
82311
|
}, async (request, reply) => {
|
|
82312
|
+
const ingestStartedMs = Date.now();
|
|
82313
|
+
const reportIngest = (outcome2) => {
|
|
82314
|
+
try {
|
|
82315
|
+
opts.onTrafficIngested?.("failure" in outcome2 ? {
|
|
82316
|
+
sourceType: TrafficSourceTypes.cloudflare,
|
|
82317
|
+
status: "failed",
|
|
82318
|
+
events: 0,
|
|
82319
|
+
crawlerHits: 0,
|
|
82320
|
+
aiReferralHits: 0,
|
|
82321
|
+
aiUserFetchHits: 0,
|
|
82322
|
+
durationMs: Date.now() - ingestStartedMs,
|
|
82323
|
+
...routeOutcomeFailure(outcome2.failure, outcome2.reasonCode)
|
|
82324
|
+
} : { sourceType: TrafficSourceTypes.cloudflare, status: "succeeded", ...outcome2, durationMs: Date.now() - ingestStartedMs });
|
|
82325
|
+
} catch {
|
|
82326
|
+
}
|
|
82327
|
+
};
|
|
81570
82328
|
const authenticated = authenticateCloudflareIngest(
|
|
81571
82329
|
request,
|
|
81572
82330
|
opts.cloudflareTrafficCredentialStore
|
|
@@ -81584,6 +82342,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81584
82342
|
if (sourceRow2.projectId !== project.id) throw authRequired();
|
|
81585
82343
|
const parsed = cloudflareWorkerIngestRequestSchema.safeParse(request.body);
|
|
81586
82344
|
if (!parsed.success) {
|
|
82345
|
+
reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
|
|
81587
82346
|
throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
|
|
81588
82347
|
}
|
|
81589
82348
|
const { workerVersion, events } = parsed.data;
|
|
@@ -81592,6 +82351,7 @@ async function trafficRoutes(app, opts) {
|
|
|
81592
82351
|
(event) => normalizeCloudflareEventHost(event.host) !== canonicalHost
|
|
81593
82352
|
);
|
|
81594
82353
|
if (wrongHostIndex !== -1) {
|
|
82354
|
+
reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
|
|
81595
82355
|
throw validationError(
|
|
81596
82356
|
`Cloudflare event at index ${wrongHostIndex} does not belong to project domain "${project.canonicalDomain}"`
|
|
81597
82357
|
);
|
|
@@ -81604,25 +82364,37 @@ async function trafficRoutes(app, opts) {
|
|
|
81604
82364
|
else normalizationDrops += 1;
|
|
81605
82365
|
}
|
|
81606
82366
|
const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
81607
|
-
|
|
81608
|
-
|
|
81609
|
-
|
|
81610
|
-
|
|
81611
|
-
|
|
81612
|
-
|
|
81613
|
-
|
|
81614
|
-
|
|
81615
|
-
|
|
81616
|
-
|
|
81617
|
-
|
|
82367
|
+
let writeResult;
|
|
82368
|
+
try {
|
|
82369
|
+
writeResult = writeTrafficEventBatch({
|
|
82370
|
+
db: app.db,
|
|
82371
|
+
projectId: project.id,
|
|
82372
|
+
sourceId,
|
|
82373
|
+
events: normalized,
|
|
82374
|
+
receivedAt,
|
|
82375
|
+
receiptTtlMs: DIRECT_PUSH_RECEIPT_TTL_MS,
|
|
82376
|
+
sampleLimit,
|
|
82377
|
+
validateSource: (latestRow) => {
|
|
82378
|
+
if (!latestRow || latestRow.projectId !== project.id || latestRow.sourceType !== TrafficSourceTypes.cloudflare || latestRow.status !== TrafficSourceStatuses.connected || !parseDirectPushCloudflareSourceConfig(latestRow.configJson) || !timingSafeEqualHex(bearerHash, latestRow.ingestTokenHash)) {
|
|
82379
|
+
throw authRequired();
|
|
82380
|
+
}
|
|
82381
|
+
},
|
|
82382
|
+
sourceUpdate: {
|
|
82383
|
+
lastWorkerVersion: workerVersion,
|
|
82384
|
+
lastSyncedAt: receivedAt,
|
|
82385
|
+
lastError: null,
|
|
82386
|
+
updatedAt: receivedAt
|
|
81618
82387
|
}
|
|
81619
|
-
}
|
|
81620
|
-
|
|
81621
|
-
|
|
81622
|
-
|
|
81623
|
-
|
|
81624
|
-
|
|
81625
|
-
|
|
82388
|
+
});
|
|
82389
|
+
} catch (err) {
|
|
82390
|
+
reportIngest({ failure: err });
|
|
82391
|
+
throw err;
|
|
82392
|
+
}
|
|
82393
|
+
reportIngest({
|
|
82394
|
+
events: writeResult.acceptedEvents,
|
|
82395
|
+
crawlerHits: writeResult.crawlerHits,
|
|
82396
|
+
aiReferralHits: writeResult.aiReferralHits,
|
|
82397
|
+
aiUserFetchHits: writeResult.aiUserFetchHits
|
|
81626
82398
|
});
|
|
81627
82399
|
return reply.status(200).send({
|
|
81628
82400
|
acceptedEvents: writeResult.acceptedEvents,
|
|
@@ -81860,6 +82632,22 @@ async function trafficRoutes(app, opts) {
|
|
|
81860
82632
|
} catch (error) {
|
|
81861
82633
|
const failedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
81862
82634
|
const safeError = error instanceof CloudflareQueueApiError ? error.message : "Cloudflare Queue sync failed; retry the active source.";
|
|
82635
|
+
try {
|
|
82636
|
+
opts.onTrafficSynced?.({
|
|
82637
|
+
status: "failed",
|
|
82638
|
+
sourceType: sourceRow2.sourceType,
|
|
82639
|
+
sourceId: sourceRow2.id,
|
|
82640
|
+
pulledEvents: 0,
|
|
82641
|
+
selfTrafficExcluded: 0,
|
|
82642
|
+
crawlerHits: 0,
|
|
82643
|
+
aiUserFetchHits: 0,
|
|
82644
|
+
aiReferralHits: 0,
|
|
82645
|
+
durationMs: Date.now() - startedMs,
|
|
82646
|
+
// A 401/403 is the stored API token; any other Queue API answer is the pull.
|
|
82647
|
+
errorCode: error instanceof CloudflareQueueApiError ? error.status === 401 || error.status === 403 ? "PROVIDER_AUTH" : "PROVIDER_PULL" : "INTERNAL"
|
|
82648
|
+
});
|
|
82649
|
+
} catch {
|
|
82650
|
+
}
|
|
81863
82651
|
app.db.transaction((tx) => {
|
|
81864
82652
|
tx.update(runs).set({ status: RunStatuses.failed, error: safeError, finishedAt: failedAt }).where(eq69(runs.id, runId)).run();
|
|
81865
82653
|
const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
@@ -82573,7 +83361,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82573
83361
|
}
|
|
82574
83362
|
}
|
|
82575
83363
|
});
|
|
82576
|
-
app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => {
|
|
83364
|
+
app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "backfill" }, async (recordOutcome) => {
|
|
82577
83365
|
const project = resolveProject(app.db, request.params.name);
|
|
82578
83366
|
const sourceRow2 = app.db.select().from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
|
|
82579
83367
|
if (!sourceRow2 || sourceRow2.projectId !== project.id) {
|
|
@@ -82714,6 +83502,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82714
83502
|
pullErrorPrefix
|
|
82715
83503
|
}).catch(() => {
|
|
82716
83504
|
});
|
|
83505
|
+
recordOutcome(null);
|
|
82717
83506
|
const response = {
|
|
82718
83507
|
sourceId: sourceRow2.id,
|
|
82719
83508
|
runId,
|
|
@@ -82724,7 +83513,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82724
83513
|
daysApplied: appliedDays
|
|
82725
83514
|
};
|
|
82726
83515
|
return response;
|
|
82727
|
-
});
|
|
83516
|
+
}));
|
|
82728
83517
|
function buildSourceDetail(projectId, row, since) {
|
|
82729
83518
|
const crawlerPathRows = app.db.select({
|
|
82730
83519
|
pathNormalized: crawlerEventsHourly.pathNormalized,
|
|
@@ -82790,7 +83579,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82790
83579
|
} : null
|
|
82791
83580
|
};
|
|
82792
83581
|
}
|
|
82793
|
-
app.post("/projects/:name/traffic/sources/:id/reset", async (request) => {
|
|
83582
|
+
app.post("/projects/:name/traffic/sources/:id/reset", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "reset" }, async () => {
|
|
82794
83583
|
const project = resolveProject(app.db, request.params.name);
|
|
82795
83584
|
const parsed = trafficResetRequestSchema.safeParse(request.body ?? {});
|
|
82796
83585
|
if (!parsed.success) {
|
|
@@ -82843,7 +83632,7 @@ async function trafficRoutes(app, opts) {
|
|
|
82843
83632
|
updatedRow = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
|
|
82844
83633
|
});
|
|
82845
83634
|
return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
|
|
82846
|
-
});
|
|
83635
|
+
}));
|
|
82847
83636
|
app.get("/projects/:name/traffic/sources", async (request) => {
|
|
82848
83637
|
const project = resolveProject(app.db, request.params.name);
|
|
82849
83638
|
const rows = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).orderBy(desc35(trafficSources.createdAt)).all();
|
|
@@ -90894,6 +91683,7 @@ function captureSimpleMeasurementDefinition(db, input) {
|
|
|
90894
91683
|
// ../api-routes/src/index.ts
|
|
90895
91684
|
async function apiRoutes(app, opts) {
|
|
90896
91685
|
app.decorate("db", opts.db);
|
|
91686
|
+
app.decorate("emitOutcome", createOutcomeEmitter(opts.onOutcome));
|
|
90897
91687
|
app.setErrorHandler((error, _request, reply) => {
|
|
90898
91688
|
if (error instanceof AppError) {
|
|
90899
91689
|
return reply.status(error.statusCode).send(error.toJSON());
|
|
@@ -91167,6 +91957,7 @@ async function apiRoutes(app, opts) {
|
|
|
91167
91957
|
cloudflareIngestIpRateLimitMax: opts.cloudflareIngestIpRateLimitMax,
|
|
91168
91958
|
defaultSampleLimit: opts.defaultTrafficSampleLimit,
|
|
91169
91959
|
onTrafficSynced: opts.onTrafficSynced,
|
|
91960
|
+
onTrafficIngested: opts.onTrafficIngested,
|
|
91170
91961
|
onScheduleUpdated: opts.onScheduleUpdated,
|
|
91171
91962
|
allowLoopbackWebhooks: opts.allowLoopbackWebhooks
|
|
91172
91963
|
});
|
|
@@ -92497,6 +93288,7 @@ export {
|
|
|
92497
93288
|
bingConnections,
|
|
92498
93289
|
bingUrlInspections,
|
|
92499
93290
|
bingKeywordStats,
|
|
93291
|
+
gaConnections,
|
|
92500
93292
|
gaTrafficSnapshots,
|
|
92501
93293
|
gaDailyTotals,
|
|
92502
93294
|
gaAcquisitionDaily,
|
|
@@ -92574,6 +93366,7 @@ export {
|
|
|
92574
93366
|
createCredentialChecker,
|
|
92575
93367
|
requireInstanceAdministrator,
|
|
92576
93368
|
hashApiKey,
|
|
93369
|
+
currentOutcomeAttribution,
|
|
92577
93370
|
notProbeRun,
|
|
92578
93371
|
writeAuditLog,
|
|
92579
93372
|
auditFromRequest,
|
|
@@ -92587,9 +93380,15 @@ export {
|
|
|
92587
93380
|
SentimentService,
|
|
92588
93381
|
registerOAuthRoutes,
|
|
92589
93382
|
registerOAuthAdminRoutes,
|
|
93383
|
+
credentialFailure,
|
|
92590
93384
|
createRunCompetitorResolver,
|
|
92591
93385
|
competitorIdentityColumns,
|
|
92592
93386
|
readMarketCompetitorNames,
|
|
93387
|
+
toAlertView,
|
|
93388
|
+
resolveDestination,
|
|
93389
|
+
webhookOutcomeTarget,
|
|
93390
|
+
webhookTargetRefusalReason,
|
|
93391
|
+
webhookResponseReason,
|
|
92593
93392
|
latestQueryBasketRevision,
|
|
92594
93393
|
ensureCurrentQueryBasketRevision,
|
|
92595
93394
|
queueRunIfProjectIdle,
|
|
@@ -92609,19 +93408,20 @@ export {
|
|
|
92609
93408
|
deliverWebhook,
|
|
92610
93409
|
redactNotificationUrl,
|
|
92611
93410
|
buildOpenApiDocument,
|
|
92612
|
-
toAlertView,
|
|
92613
|
-
resolveDestination,
|
|
92614
93411
|
getPlaceDetails,
|
|
92615
93412
|
hashPlaceDetails,
|
|
92616
93413
|
getPlaceReviewSignals,
|
|
92617
93414
|
GSC_REPORTING_TIME_ZONE,
|
|
92618
93415
|
GSC_DATA_LAG_DAYS,
|
|
93416
|
+
GoogleAuthError,
|
|
93417
|
+
GoogleApiError,
|
|
92619
93418
|
getAuthUrl,
|
|
92620
93419
|
exchangeCode,
|
|
92621
93420
|
refreshAccessToken,
|
|
92622
93421
|
fetchSearchAnalytics,
|
|
92623
93422
|
inspectUrl,
|
|
92624
93423
|
GBP_DAILY_METRICS,
|
|
93424
|
+
GbpApiError,
|
|
92625
93425
|
listLocations,
|
|
92626
93426
|
formatStorefrontAddress,
|
|
92627
93427
|
buildLocationProfileFields,
|
|
@@ -92636,6 +93436,7 @@ export {
|
|
|
92636
93436
|
hashAttributes,
|
|
92637
93437
|
listReviews,
|
|
92638
93438
|
classifyReviewsError,
|
|
93439
|
+
BingApiError,
|
|
92639
93440
|
getUrlInfo,
|
|
92640
93441
|
getCrawlIssues,
|
|
92641
93442
|
EgressRefusedError,
|