@ainyc/canonry 2.12.1 → 2.13.2
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/{index-Ckr4V5dK.js → index-CKzK0os-.js} +93 -93
- package/assets/index.html +1 -1
- package/dist/{chunk-FCYNFM4B.js → chunk-DHMCIJMQ.js} +639 -1
- package/dist/{chunk-PLI7EOPM.js → chunk-XJS7NALL.js} +120 -2
- package/dist/cli.js +94 -4
- package/dist/index.js +2 -2
- package/dist/mcp.js +16 -1
- package/package.json +6 -6
package/assets/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
|
|
13
13
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
14
14
|
<title>Canonry</title>
|
|
15
|
-
<script type="module" crossorigin src="./assets/index-
|
|
15
|
+
<script type="module" crossorigin src="./assets/index-CKzK0os-.js"></script>
|
|
16
16
|
<link rel="stylesheet" crossorigin href="./assets/index-CAewPdsZ.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
ApiClient,
|
|
7
7
|
AppError,
|
|
8
8
|
CcReleaseSyncStatuses,
|
|
9
|
+
CheckCategories,
|
|
10
|
+
CheckScopes,
|
|
11
|
+
CheckStatuses,
|
|
9
12
|
CitationStates,
|
|
10
13
|
MemorySources,
|
|
11
14
|
RunKinds,
|
|
@@ -50,12 +53,13 @@ import {
|
|
|
50
53
|
scheduleUpsertRequestSchema,
|
|
51
54
|
serializeRunError,
|
|
52
55
|
snapshotRequestSchema,
|
|
56
|
+
summarizeCheckResults,
|
|
53
57
|
unsupportedKind,
|
|
54
58
|
validationError,
|
|
55
59
|
visibilityStateFromAnswerMentioned,
|
|
56
60
|
windowCutoff,
|
|
57
61
|
wordpressEnvSchema
|
|
58
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-XJS7NALL.js";
|
|
59
63
|
import {
|
|
60
64
|
IntelligenceService,
|
|
61
65
|
agentMemory,
|
|
@@ -5303,6 +5307,44 @@ var routeCatalog = [
|
|
|
5303
5307
|
404: { description: "Project not found." }
|
|
5304
5308
|
}
|
|
5305
5309
|
},
|
|
5310
|
+
{
|
|
5311
|
+
method: "get",
|
|
5312
|
+
path: "/api/v1/doctor",
|
|
5313
|
+
summary: "Run global health checks",
|
|
5314
|
+
description: "Runs all global-scope checks (provider keys, etc.). Use ?check=<id> or ?check=<prefix>* (comma-separated) to filter. Returns a structured DoctorReport with per-check status, code, summary, remediation, and details.",
|
|
5315
|
+
tags: ["doctor"],
|
|
5316
|
+
parameters: [
|
|
5317
|
+
{
|
|
5318
|
+
name: "check",
|
|
5319
|
+
in: "query",
|
|
5320
|
+
description: 'Optional comma-separated list of check IDs or wildcard prefixes (e.g. "config.*").',
|
|
5321
|
+
schema: stringSchema
|
|
5322
|
+
}
|
|
5323
|
+
],
|
|
5324
|
+
responses: {
|
|
5325
|
+
200: { description: "Doctor report returned." }
|
|
5326
|
+
}
|
|
5327
|
+
},
|
|
5328
|
+
{
|
|
5329
|
+
method: "get",
|
|
5330
|
+
path: "/api/v1/projects/{name}/doctor",
|
|
5331
|
+
summary: "Run project health checks",
|
|
5332
|
+
description: "Runs project-scoped checks (Google auth, GA auth, etc.). Use ?check=<id> or ?check=<prefix>* (comma-separated) to filter \u2014 e.g. ?check=google.* for just Google auth checks. Returns a structured DoctorReport.",
|
|
5333
|
+
tags: ["doctor"],
|
|
5334
|
+
parameters: [
|
|
5335
|
+
nameParameter,
|
|
5336
|
+
{
|
|
5337
|
+
name: "check",
|
|
5338
|
+
in: "query",
|
|
5339
|
+
description: 'Optional comma-separated list of check IDs or wildcard prefixes (e.g. "google.auth.*").',
|
|
5340
|
+
schema: stringSchema
|
|
5341
|
+
}
|
|
5342
|
+
],
|
|
5343
|
+
responses: {
|
|
5344
|
+
200: { description: "Doctor report returned." },
|
|
5345
|
+
404: { description: "Project not found." }
|
|
5346
|
+
}
|
|
5347
|
+
},
|
|
5306
5348
|
{
|
|
5307
5349
|
method: "get",
|
|
5308
5350
|
path: "/api/v1/backlinks/status",
|
|
@@ -11193,6 +11235,595 @@ async function backlinksRoutes(app, opts) {
|
|
|
11193
11235
|
);
|
|
11194
11236
|
}
|
|
11195
11237
|
|
|
11238
|
+
// ../api-routes/src/doctor/checks/ga-auth.ts
|
|
11239
|
+
async function checkServiceAccount(conn) {
|
|
11240
|
+
if (!conn.propertyId) {
|
|
11241
|
+
return {
|
|
11242
|
+
status: CheckStatuses.fail,
|
|
11243
|
+
code: "ga.auth.no-property-selected",
|
|
11244
|
+
summary: "GA4 service account record has no property ID set.",
|
|
11245
|
+
remediation: "Set a propertyId in the GA4 credential record (config.yaml `ga4.connections[].propertyId`)."
|
|
11246
|
+
};
|
|
11247
|
+
}
|
|
11248
|
+
if (!conn.clientEmail || !conn.privateKey) {
|
|
11249
|
+
return {
|
|
11250
|
+
status: CheckStatuses.fail,
|
|
11251
|
+
code: "ga.auth.service-account-incomplete",
|
|
11252
|
+
summary: "GA4 service account is missing clientEmail or privateKey.",
|
|
11253
|
+
remediation: "Provide a complete service account JSON key (clientEmail + privateKey) in config.yaml.",
|
|
11254
|
+
details: {
|
|
11255
|
+
hasClientEmail: Boolean(conn.clientEmail),
|
|
11256
|
+
hasPrivateKey: Boolean(conn.privateKey)
|
|
11257
|
+
}
|
|
11258
|
+
};
|
|
11259
|
+
}
|
|
11260
|
+
try {
|
|
11261
|
+
await verifyConnection(conn.clientEmail, conn.privateKey, conn.propertyId);
|
|
11262
|
+
} catch (err) {
|
|
11263
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11264
|
+
return {
|
|
11265
|
+
status: CheckStatuses.fail,
|
|
11266
|
+
code: "ga.auth.verify-failed",
|
|
11267
|
+
summary: "GA4 service account could not authenticate against the configured property.",
|
|
11268
|
+
remediation: `Verify the service account has Viewer access on property ${conn.propertyId}, and that the private key in config.yaml is the active key for the service account.`,
|
|
11269
|
+
details: { propertyId: conn.propertyId, error: message, authMethod: "service-account" }
|
|
11270
|
+
};
|
|
11271
|
+
}
|
|
11272
|
+
return {
|
|
11273
|
+
status: CheckStatuses.ok,
|
|
11274
|
+
code: "ga.auth.verified",
|
|
11275
|
+
summary: `GA4 property ${conn.propertyId} is reachable with the configured service account.`,
|
|
11276
|
+
remediation: null,
|
|
11277
|
+
details: { propertyId: conn.propertyId, clientEmail: conn.clientEmail, authMethod: "service-account" }
|
|
11278
|
+
};
|
|
11279
|
+
}
|
|
11280
|
+
async function checkOAuthConnection(ctx, projectName, conn) {
|
|
11281
|
+
if (!conn.propertyId) {
|
|
11282
|
+
return {
|
|
11283
|
+
status: CheckStatuses.fail,
|
|
11284
|
+
code: "ga.auth.no-property-selected",
|
|
11285
|
+
summary: "GA4 OAuth connection has no property selected.",
|
|
11286
|
+
remediation: `Run \`canonry google connect ${projectName} --type ga4\` to select a property.`
|
|
11287
|
+
};
|
|
11288
|
+
}
|
|
11289
|
+
if (!conn.refreshToken) {
|
|
11290
|
+
return {
|
|
11291
|
+
status: CheckStatuses.fail,
|
|
11292
|
+
code: "ga.auth.no-refresh-token",
|
|
11293
|
+
summary: "GA4 OAuth connection has no refresh token stored.",
|
|
11294
|
+
remediation: `Run \`canonry google connect ${projectName} --type ga4\` to re-authorize and capture a refresh token.`,
|
|
11295
|
+
details: { propertyId: conn.propertyId }
|
|
11296
|
+
};
|
|
11297
|
+
}
|
|
11298
|
+
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
11299
|
+
if (!auth.clientId || !auth.clientSecret) {
|
|
11300
|
+
return {
|
|
11301
|
+
status: CheckStatuses.fail,
|
|
11302
|
+
code: "ga.auth.oauth-not-configured",
|
|
11303
|
+
summary: "GA4 OAuth connection exists but Google OAuth client ID/secret is missing.",
|
|
11304
|
+
remediation: "Set `google.clientId` and `google.clientSecret` in ~/.canonry/config.yaml."
|
|
11305
|
+
};
|
|
11306
|
+
}
|
|
11307
|
+
let accessToken;
|
|
11308
|
+
try {
|
|
11309
|
+
const tokens = await refreshAccessToken(auth.clientId, auth.clientSecret, conn.refreshToken);
|
|
11310
|
+
accessToken = tokens.access_token;
|
|
11311
|
+
} catch (err) {
|
|
11312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11313
|
+
return {
|
|
11314
|
+
status: CheckStatuses.fail,
|
|
11315
|
+
code: "ga.auth.refresh-failed",
|
|
11316
|
+
summary: "GA4 OAuth refresh token rejected by Google.",
|
|
11317
|
+
remediation: `Run \`canonry google connect ${projectName} --type ga4\` to re-authorize.`,
|
|
11318
|
+
details: { propertyId: conn.propertyId, error: message, authMethod: "oauth" }
|
|
11319
|
+
};
|
|
11320
|
+
}
|
|
11321
|
+
try {
|
|
11322
|
+
await verifyConnectionWithToken(accessToken, conn.propertyId);
|
|
11323
|
+
} catch (err) {
|
|
11324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11325
|
+
return {
|
|
11326
|
+
status: CheckStatuses.fail,
|
|
11327
|
+
code: "ga.auth.verify-failed",
|
|
11328
|
+
summary: "GA4 OAuth token cannot reach the configured property.",
|
|
11329
|
+
remediation: `Verify the authorized Google account has access to property ${conn.propertyId}, or run \`canonry google connect ${projectName} --type ga4\` to re-authorize.`,
|
|
11330
|
+
details: { propertyId: conn.propertyId, error: message, authMethod: "oauth" }
|
|
11331
|
+
};
|
|
11332
|
+
}
|
|
11333
|
+
return {
|
|
11334
|
+
status: CheckStatuses.ok,
|
|
11335
|
+
code: "ga.auth.verified",
|
|
11336
|
+
summary: `GA4 property ${conn.propertyId} is reachable via OAuth.`,
|
|
11337
|
+
remediation: null,
|
|
11338
|
+
details: { propertyId: conn.propertyId, authMethod: "oauth" }
|
|
11339
|
+
};
|
|
11340
|
+
}
|
|
11341
|
+
var ga4ConnectionCheck = {
|
|
11342
|
+
id: "ga.auth.connection",
|
|
11343
|
+
category: CheckCategories.auth,
|
|
11344
|
+
scope: CheckScopes.project,
|
|
11345
|
+
title: "GA4 connection",
|
|
11346
|
+
run: async (ctx) => {
|
|
11347
|
+
if (!ctx.project) {
|
|
11348
|
+
return {
|
|
11349
|
+
status: CheckStatuses.skipped,
|
|
11350
|
+
code: "ga.auth.no-project",
|
|
11351
|
+
summary: "Project context required.",
|
|
11352
|
+
remediation: null
|
|
11353
|
+
};
|
|
11354
|
+
}
|
|
11355
|
+
const saStore = ctx.ga4CredentialStore;
|
|
11356
|
+
const oauthStore = ctx.googleConnectionStore;
|
|
11357
|
+
if (!saStore && !oauthStore) {
|
|
11358
|
+
return {
|
|
11359
|
+
status: CheckStatuses.skipped,
|
|
11360
|
+
code: "ga.auth.store-unavailable",
|
|
11361
|
+
summary: "No GA4 credential store configured for this deployment.",
|
|
11362
|
+
remediation: null
|
|
11363
|
+
};
|
|
11364
|
+
}
|
|
11365
|
+
const saConn = saStore?.getConnection(ctx.project.name);
|
|
11366
|
+
if (saConn) return checkServiceAccount(saConn);
|
|
11367
|
+
const oauthConn = oauthStore?.getConnection(ctx.project.canonicalDomain, "ga4");
|
|
11368
|
+
if (oauthConn) return checkOAuthConnection(ctx, ctx.project.name, oauthConn);
|
|
11369
|
+
return {
|
|
11370
|
+
status: CheckStatuses.warn,
|
|
11371
|
+
code: "ga.auth.no-connection",
|
|
11372
|
+
summary: "No GA4 connection configured for this project.",
|
|
11373
|
+
remediation: `Run \`canonry google connect ${ctx.project.name} --type ga4\` to authorize via OAuth, or set up a service account in ~/.canonry/config.yaml under \`ga4.connections\`.`
|
|
11374
|
+
};
|
|
11375
|
+
}
|
|
11376
|
+
};
|
|
11377
|
+
var GA_AUTH_CHECKS = [ga4ConnectionCheck];
|
|
11378
|
+
|
|
11379
|
+
// ../api-routes/src/doctor/checks/google-auth.ts
|
|
11380
|
+
var REQUIRED_GSC_SCOPES = [GSC_SCOPE, INDEXING_SCOPE];
|
|
11381
|
+
async function resolveAccessToken(ctx) {
|
|
11382
|
+
if (!ctx.project) {
|
|
11383
|
+
return { ok: false, output: skippedNoProject() };
|
|
11384
|
+
}
|
|
11385
|
+
const store = ctx.googleConnectionStore;
|
|
11386
|
+
if (!store) {
|
|
11387
|
+
return {
|
|
11388
|
+
ok: false,
|
|
11389
|
+
output: {
|
|
11390
|
+
status: CheckStatuses.skipped,
|
|
11391
|
+
code: "google.auth.store-unavailable",
|
|
11392
|
+
summary: "Google connection store is not configured for this deployment.",
|
|
11393
|
+
remediation: null
|
|
11394
|
+
}
|
|
11395
|
+
};
|
|
11396
|
+
}
|
|
11397
|
+
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
11398
|
+
if (!auth.clientId || !auth.clientSecret) {
|
|
11399
|
+
return {
|
|
11400
|
+
ok: false,
|
|
11401
|
+
output: {
|
|
11402
|
+
status: CheckStatuses.fail,
|
|
11403
|
+
code: "google.auth.oauth-not-configured",
|
|
11404
|
+
summary: "Google OAuth client ID or secret is missing.",
|
|
11405
|
+
remediation: "Set Google OAuth credentials in ~/.canonry/config.yaml under `google.clientId` and `google.clientSecret`."
|
|
11406
|
+
}
|
|
11407
|
+
};
|
|
11408
|
+
}
|
|
11409
|
+
const conn = store.getConnection(ctx.project.canonicalDomain, "gsc");
|
|
11410
|
+
if (!conn) {
|
|
11411
|
+
return {
|
|
11412
|
+
ok: false,
|
|
11413
|
+
output: {
|
|
11414
|
+
status: CheckStatuses.fail,
|
|
11415
|
+
code: "google.auth.no-connection",
|
|
11416
|
+
summary: `No GSC connection for ${ctx.project.canonicalDomain}.`,
|
|
11417
|
+
remediation: `Run \`canonry google connect ${ctx.project.name} --type gsc\` to authorize.`
|
|
11418
|
+
}
|
|
11419
|
+
};
|
|
11420
|
+
}
|
|
11421
|
+
if (!conn.refreshToken) {
|
|
11422
|
+
return {
|
|
11423
|
+
ok: false,
|
|
11424
|
+
output: {
|
|
11425
|
+
status: CheckStatuses.fail,
|
|
11426
|
+
code: "google.auth.no-refresh-token",
|
|
11427
|
+
summary: "GSC connection exists but has no refresh token stored.",
|
|
11428
|
+
remediation: `Run \`canonry google connect ${ctx.project.name} --type gsc\` to re-authorize and capture a refresh token.`,
|
|
11429
|
+
details: { domain: conn.domain }
|
|
11430
|
+
}
|
|
11431
|
+
};
|
|
11432
|
+
}
|
|
11433
|
+
try {
|
|
11434
|
+
const tokens = await refreshAccessToken(auth.clientId, auth.clientSecret, conn.refreshToken);
|
|
11435
|
+
return { ok: true, token: { accessToken: tokens.access_token } };
|
|
11436
|
+
} catch (err) {
|
|
11437
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11438
|
+
return {
|
|
11439
|
+
ok: false,
|
|
11440
|
+
output: {
|
|
11441
|
+
status: CheckStatuses.fail,
|
|
11442
|
+
code: "google.auth.refresh-failed",
|
|
11443
|
+
summary: "Refresh token rejected by Google.",
|
|
11444
|
+
remediation: `Run \`canonry google connect ${ctx.project.name} --type gsc\` to re-authorize. Refresh tokens are revoked if the user changes their password or the OAuth client is rotated.`,
|
|
11445
|
+
details: { domain: conn.domain, error: message }
|
|
11446
|
+
}
|
|
11447
|
+
};
|
|
11448
|
+
}
|
|
11449
|
+
}
|
|
11450
|
+
function skippedNoProject() {
|
|
11451
|
+
return {
|
|
11452
|
+
status: CheckStatuses.skipped,
|
|
11453
|
+
code: "google.auth.no-project",
|
|
11454
|
+
summary: "Project context required.",
|
|
11455
|
+
remediation: null
|
|
11456
|
+
};
|
|
11457
|
+
}
|
|
11458
|
+
var connectionCheck = {
|
|
11459
|
+
id: "google.auth.connection",
|
|
11460
|
+
category: CheckCategories.auth,
|
|
11461
|
+
scope: CheckScopes.project,
|
|
11462
|
+
title: "GSC OAuth connection",
|
|
11463
|
+
run: async (ctx) => {
|
|
11464
|
+
const resolved = await resolveAccessToken(ctx);
|
|
11465
|
+
if (!resolved.ok) return resolved.output;
|
|
11466
|
+
return {
|
|
11467
|
+
status: CheckStatuses.ok,
|
|
11468
|
+
code: "google.auth.connected",
|
|
11469
|
+
summary: "GSC OAuth connection is valid and refreshable.",
|
|
11470
|
+
remediation: null
|
|
11471
|
+
};
|
|
11472
|
+
}
|
|
11473
|
+
};
|
|
11474
|
+
var propertyAccessCheck = {
|
|
11475
|
+
id: "google.auth.property-access",
|
|
11476
|
+
category: CheckCategories.auth,
|
|
11477
|
+
scope: CheckScopes.project,
|
|
11478
|
+
title: "GSC property access",
|
|
11479
|
+
run: async (ctx) => {
|
|
11480
|
+
if (!ctx.project) return skippedNoProject();
|
|
11481
|
+
const store = ctx.googleConnectionStore;
|
|
11482
|
+
if (!store) {
|
|
11483
|
+
return {
|
|
11484
|
+
status: CheckStatuses.skipped,
|
|
11485
|
+
code: "google.auth.store-unavailable",
|
|
11486
|
+
summary: "Google connection store is not configured for this deployment.",
|
|
11487
|
+
remediation: null
|
|
11488
|
+
};
|
|
11489
|
+
}
|
|
11490
|
+
const conn = store.getConnection(ctx.project.canonicalDomain, "gsc");
|
|
11491
|
+
if (!conn) {
|
|
11492
|
+
return {
|
|
11493
|
+
status: CheckStatuses.skipped,
|
|
11494
|
+
code: "google.auth.no-connection",
|
|
11495
|
+
summary: "No GSC connection \u2014 run google.auth.connection first.",
|
|
11496
|
+
remediation: null
|
|
11497
|
+
};
|
|
11498
|
+
}
|
|
11499
|
+
if (!conn.propertyId) {
|
|
11500
|
+
return {
|
|
11501
|
+
status: CheckStatuses.fail,
|
|
11502
|
+
code: "google.auth.no-property-selected",
|
|
11503
|
+
summary: "GSC connection has no property selected.",
|
|
11504
|
+
remediation: `Run \`canonry google properties ${ctx.project.name}\` to list available properties, then \`canonry google set-property ${ctx.project.name} <siteUrl>\`.`
|
|
11505
|
+
};
|
|
11506
|
+
}
|
|
11507
|
+
const resolved = await resolveAccessToken(ctx);
|
|
11508
|
+
if (!resolved.ok) {
|
|
11509
|
+
return {
|
|
11510
|
+
status: CheckStatuses.skipped,
|
|
11511
|
+
code: "google.auth.token-unresolved",
|
|
11512
|
+
summary: "Skipped \u2014 token could not be refreshed (see google.auth.connection).",
|
|
11513
|
+
remediation: null
|
|
11514
|
+
};
|
|
11515
|
+
}
|
|
11516
|
+
let sites;
|
|
11517
|
+
try {
|
|
11518
|
+
sites = await listSites(resolved.token.accessToken);
|
|
11519
|
+
} catch (err) {
|
|
11520
|
+
if (err instanceof GoogleApiError && err.status === 403) {
|
|
11521
|
+
return {
|
|
11522
|
+
status: CheckStatuses.fail,
|
|
11523
|
+
code: "google.auth.principal-forbidden",
|
|
11524
|
+
summary: "The authorized Google account is forbidden from listing GSC sites.",
|
|
11525
|
+
remediation: `Reconnect with a Google account that has access in Search Console: \`canonry google connect ${ctx.project.name} --type gsc\`.`,
|
|
11526
|
+
details: { error: err.message }
|
|
11527
|
+
};
|
|
11528
|
+
}
|
|
11529
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11530
|
+
return {
|
|
11531
|
+
status: CheckStatuses.fail,
|
|
11532
|
+
code: "google.auth.list-sites-failed",
|
|
11533
|
+
summary: "Failed to list GSC sites for the authorized account.",
|
|
11534
|
+
remediation: "Check Google Search Console availability, then re-run.",
|
|
11535
|
+
details: { error: message }
|
|
11536
|
+
};
|
|
11537
|
+
}
|
|
11538
|
+
const match = sites.find((site) => site.siteUrl === conn.propertyId);
|
|
11539
|
+
if (!match) {
|
|
11540
|
+
return {
|
|
11541
|
+
status: CheckStatuses.fail,
|
|
11542
|
+
code: "google.auth.property-not-accessible",
|
|
11543
|
+
summary: `Selected property "${conn.propertyId}" is not in the authorized account's accessible sites list.`,
|
|
11544
|
+
remediation: `Either grant the authorizing Google account access to "${conn.propertyId}" in Search Console, or run \`canonry google set-property ${ctx.project.name} <siteUrl>\` to pick an accessible site.`,
|
|
11545
|
+
details: {
|
|
11546
|
+
selectedProperty: conn.propertyId,
|
|
11547
|
+
accessibleSites: sites.map((s) => s.siteUrl)
|
|
11548
|
+
}
|
|
11549
|
+
};
|
|
11550
|
+
}
|
|
11551
|
+
return {
|
|
11552
|
+
status: CheckStatuses.ok,
|
|
11553
|
+
code: "google.auth.property-accessible",
|
|
11554
|
+
summary: `Property "${conn.propertyId}" is accessible (permission: ${match.permissionLevel}).`,
|
|
11555
|
+
remediation: null,
|
|
11556
|
+
details: {
|
|
11557
|
+
selectedProperty: conn.propertyId,
|
|
11558
|
+
permissionLevel: match.permissionLevel
|
|
11559
|
+
}
|
|
11560
|
+
};
|
|
11561
|
+
}
|
|
11562
|
+
};
|
|
11563
|
+
var redirectUriCheck = {
|
|
11564
|
+
id: "google.auth.redirect-uri",
|
|
11565
|
+
category: CheckCategories.auth,
|
|
11566
|
+
scope: CheckScopes.project,
|
|
11567
|
+
title: "OAuth redirect URI",
|
|
11568
|
+
run: async (ctx) => {
|
|
11569
|
+
if (!ctx.project) return skippedNoProject();
|
|
11570
|
+
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
11571
|
+
if (!auth.clientId || !auth.clientSecret) {
|
|
11572
|
+
return {
|
|
11573
|
+
status: CheckStatuses.fail,
|
|
11574
|
+
code: "google.auth.oauth-not-configured",
|
|
11575
|
+
summary: "Google OAuth client ID or secret is missing.",
|
|
11576
|
+
remediation: "Set `google.clientId` and `google.clientSecret` in ~/.canonry/config.yaml."
|
|
11577
|
+
};
|
|
11578
|
+
}
|
|
11579
|
+
if (!ctx.redirectUri) {
|
|
11580
|
+
return {
|
|
11581
|
+
status: CheckStatuses.warn,
|
|
11582
|
+
code: "google.auth.redirect-uri-auto-detected",
|
|
11583
|
+
summary: "No publicUrl configured \u2014 OAuth callback will be auto-detected from request headers each connect.",
|
|
11584
|
+
remediation: "Set `publicUrl` in ~/.canonry/config.yaml so canonry uses a stable redirect URI (e.g. http://localhost:4100). Then register that exact URI in Google Cloud Console under your OAuth client."
|
|
11585
|
+
};
|
|
11586
|
+
}
|
|
11587
|
+
let parsed;
|
|
11588
|
+
try {
|
|
11589
|
+
parsed = new URL(ctx.redirectUri);
|
|
11590
|
+
} catch {
|
|
11591
|
+
return {
|
|
11592
|
+
status: CheckStatuses.fail,
|
|
11593
|
+
code: "google.auth.redirect-uri-invalid",
|
|
11594
|
+
summary: `Configured redirect URI is not a valid URL: ${ctx.redirectUri}`,
|
|
11595
|
+
remediation: "Set `publicUrl` to a valid http(s) URL in ~/.canonry/config.yaml.",
|
|
11596
|
+
details: { redirectUri: ctx.redirectUri }
|
|
11597
|
+
};
|
|
11598
|
+
}
|
|
11599
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
11600
|
+
return {
|
|
11601
|
+
status: CheckStatuses.fail,
|
|
11602
|
+
code: "google.auth.redirect-uri-invalid",
|
|
11603
|
+
summary: `Redirect URI must use http or https: ${ctx.redirectUri}`,
|
|
11604
|
+
remediation: "Set `publicUrl` to an http(s) URL.",
|
|
11605
|
+
details: { redirectUri: ctx.redirectUri }
|
|
11606
|
+
};
|
|
11607
|
+
}
|
|
11608
|
+
return {
|
|
11609
|
+
status: CheckStatuses.ok,
|
|
11610
|
+
code: "google.auth.redirect-uri-configured",
|
|
11611
|
+
summary: `Redirect URI is ${ctx.redirectUri}.`,
|
|
11612
|
+
remediation: `Ensure this exact URI is listed in your OAuth client's authorized redirect URIs in Google Cloud Console.`,
|
|
11613
|
+
details: { redirectUri: ctx.redirectUri }
|
|
11614
|
+
};
|
|
11615
|
+
}
|
|
11616
|
+
};
|
|
11617
|
+
var scopesCheck = {
|
|
11618
|
+
id: "google.auth.scopes",
|
|
11619
|
+
category: CheckCategories.auth,
|
|
11620
|
+
scope: CheckScopes.project,
|
|
11621
|
+
title: "GSC granted scopes",
|
|
11622
|
+
run: async (ctx) => {
|
|
11623
|
+
if (!ctx.project) return skippedNoProject();
|
|
11624
|
+
const store = ctx.googleConnectionStore;
|
|
11625
|
+
if (!store) {
|
|
11626
|
+
return {
|
|
11627
|
+
status: CheckStatuses.skipped,
|
|
11628
|
+
code: "google.auth.store-unavailable",
|
|
11629
|
+
summary: "Google connection store is not configured for this deployment.",
|
|
11630
|
+
remediation: null
|
|
11631
|
+
};
|
|
11632
|
+
}
|
|
11633
|
+
const conn = store.getConnection(ctx.project.canonicalDomain, "gsc");
|
|
11634
|
+
if (!conn) {
|
|
11635
|
+
return {
|
|
11636
|
+
status: CheckStatuses.skipped,
|
|
11637
|
+
code: "google.auth.no-connection",
|
|
11638
|
+
summary: "No GSC connection \u2014 run google.auth.connection first.",
|
|
11639
|
+
remediation: null
|
|
11640
|
+
};
|
|
11641
|
+
}
|
|
11642
|
+
const granted = new Set(conn.scopes ?? []);
|
|
11643
|
+
const missing = REQUIRED_GSC_SCOPES.filter((scope) => !granted.has(scope));
|
|
11644
|
+
if (missing.length === 0) {
|
|
11645
|
+
return {
|
|
11646
|
+
status: CheckStatuses.ok,
|
|
11647
|
+
code: "google.auth.scopes-ok",
|
|
11648
|
+
summary: "All required GSC scopes are granted.",
|
|
11649
|
+
remediation: null,
|
|
11650
|
+
details: { granted: [...granted] }
|
|
11651
|
+
};
|
|
11652
|
+
}
|
|
11653
|
+
const gscMissing = missing.includes(GSC_SCOPE);
|
|
11654
|
+
const indexingOnlyMissing = !gscMissing && missing.includes(INDEXING_SCOPE) && missing.length === 1;
|
|
11655
|
+
return {
|
|
11656
|
+
status: indexingOnlyMissing ? CheckStatuses.warn : CheckStatuses.fail,
|
|
11657
|
+
code: indexingOnlyMissing ? "google.auth.indexing-scope-missing" : "google.auth.required-scope-missing",
|
|
11658
|
+
summary: indexingOnlyMissing ? "Indexing API scope is not granted \u2014 `canonry google request-indexing` will fail." : `Missing required scopes: ${missing.join(", ")}.`,
|
|
11659
|
+
remediation: `Reconnect to grant missing scopes: \`canonry google connect ${ctx.project.name} --type gsc\`.`,
|
|
11660
|
+
details: { granted: [...granted], missing }
|
|
11661
|
+
};
|
|
11662
|
+
}
|
|
11663
|
+
};
|
|
11664
|
+
var GOOGLE_AUTH_CHECKS = [
|
|
11665
|
+
connectionCheck,
|
|
11666
|
+
propertyAccessCheck,
|
|
11667
|
+
redirectUriCheck,
|
|
11668
|
+
scopesCheck
|
|
11669
|
+
];
|
|
11670
|
+
var GOOGLE_AUTH_CHECK_BY_ID = Object.fromEntries(
|
|
11671
|
+
GOOGLE_AUTH_CHECKS.map((check) => [check.id, check])
|
|
11672
|
+
);
|
|
11673
|
+
|
|
11674
|
+
// ../api-routes/src/doctor/checks/providers.ts
|
|
11675
|
+
var providersConfiguredCheck = {
|
|
11676
|
+
id: "config.providers",
|
|
11677
|
+
category: CheckCategories.providers,
|
|
11678
|
+
scope: CheckScopes.global,
|
|
11679
|
+
title: "Provider keys",
|
|
11680
|
+
run: (ctx) => {
|
|
11681
|
+
const summary = ctx.providerSummary;
|
|
11682
|
+
if (!summary) {
|
|
11683
|
+
return {
|
|
11684
|
+
status: CheckStatuses.skipped,
|
|
11685
|
+
code: "providers.summary-unavailable",
|
|
11686
|
+
summary: "Provider summary is not available in this deployment.",
|
|
11687
|
+
remediation: null
|
|
11688
|
+
};
|
|
11689
|
+
}
|
|
11690
|
+
const configured = summary.filter((entry) => entry.configured).map((entry) => entry.name);
|
|
11691
|
+
const total = summary.length;
|
|
11692
|
+
if (configured.length === 0) {
|
|
11693
|
+
return {
|
|
11694
|
+
status: CheckStatuses.fail,
|
|
11695
|
+
code: "providers.none-configured",
|
|
11696
|
+
summary: "No answer-engine providers have credentials configured.",
|
|
11697
|
+
remediation: "Run `canonry init` to set provider keys interactively, or add them via flags (`--gemini-key`, `--openai-key`, `--claude-key`, `--perplexity-key`).",
|
|
11698
|
+
details: { available: summary.map((entry) => entry.name) }
|
|
11699
|
+
};
|
|
11700
|
+
}
|
|
11701
|
+
return {
|
|
11702
|
+
status: CheckStatuses.ok,
|
|
11703
|
+
code: "providers.configured",
|
|
11704
|
+
summary: `${configured.length} of ${total} providers configured: ${configured.join(", ")}.`,
|
|
11705
|
+
remediation: null,
|
|
11706
|
+
details: { configured, total }
|
|
11707
|
+
};
|
|
11708
|
+
}
|
|
11709
|
+
};
|
|
11710
|
+
var PROVIDERS_CHECKS = [providersConfiguredCheck];
|
|
11711
|
+
|
|
11712
|
+
// ../api-routes/src/doctor/registry.ts
|
|
11713
|
+
var ALL_CHECKS = [
|
|
11714
|
+
...GOOGLE_AUTH_CHECKS,
|
|
11715
|
+
...GA_AUTH_CHECKS,
|
|
11716
|
+
...PROVIDERS_CHECKS
|
|
11717
|
+
];
|
|
11718
|
+
var CHECK_BY_ID = Object.fromEntries(
|
|
11719
|
+
ALL_CHECKS.map((check) => [check.id, check])
|
|
11720
|
+
);
|
|
11721
|
+
|
|
11722
|
+
// ../api-routes/src/doctor/runner.ts
|
|
11723
|
+
function matchesCheckId(checkId, filters) {
|
|
11724
|
+
if (filters.length === 0) return true;
|
|
11725
|
+
for (const filter of filters) {
|
|
11726
|
+
if (filter === checkId) return true;
|
|
11727
|
+
if (filter.endsWith("*")) {
|
|
11728
|
+
const prefix = filter.slice(0, -1);
|
|
11729
|
+
if (checkId.startsWith(prefix)) return true;
|
|
11730
|
+
}
|
|
11731
|
+
}
|
|
11732
|
+
return false;
|
|
11733
|
+
}
|
|
11734
|
+
async function runChecks(ctx, checks, options = {}) {
|
|
11735
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
11736
|
+
const filters = options.checkIds ?? [];
|
|
11737
|
+
const targetScope = ctx.project ? CheckScopes.project : CheckScopes.global;
|
|
11738
|
+
const projectName = ctx.project?.name ?? null;
|
|
11739
|
+
const selected = checks.filter((check) => {
|
|
11740
|
+
if (check.scope !== targetScope) return false;
|
|
11741
|
+
return matchesCheckId(check.id, filters);
|
|
11742
|
+
});
|
|
11743
|
+
const results = [];
|
|
11744
|
+
for (const definition of selected) {
|
|
11745
|
+
const checkStarted = Date.now();
|
|
11746
|
+
let output;
|
|
11747
|
+
try {
|
|
11748
|
+
output = await definition.run(ctx);
|
|
11749
|
+
} catch (err) {
|
|
11750
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11751
|
+
output = {
|
|
11752
|
+
status: CheckStatuses.fail,
|
|
11753
|
+
code: `${definition.id}.runtime-error`,
|
|
11754
|
+
summary: `Check threw an unexpected error: ${message}`,
|
|
11755
|
+
remediation: null,
|
|
11756
|
+
details: { error: message }
|
|
11757
|
+
};
|
|
11758
|
+
}
|
|
11759
|
+
results.push({
|
|
11760
|
+
id: definition.id,
|
|
11761
|
+
category: definition.category,
|
|
11762
|
+
scope: definition.scope,
|
|
11763
|
+
title: definition.title,
|
|
11764
|
+
status: output.status,
|
|
11765
|
+
code: output.code,
|
|
11766
|
+
summary: output.summary,
|
|
11767
|
+
remediation: output.remediation ?? null,
|
|
11768
|
+
details: output.details,
|
|
11769
|
+
durationMs: Date.now() - checkStarted
|
|
11770
|
+
});
|
|
11771
|
+
}
|
|
11772
|
+
return {
|
|
11773
|
+
scope: targetScope,
|
|
11774
|
+
project: projectName,
|
|
11775
|
+
generatedAt: startedAt.toISOString(),
|
|
11776
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
11777
|
+
summary: summarizeCheckResults(results),
|
|
11778
|
+
checks: results
|
|
11779
|
+
};
|
|
11780
|
+
}
|
|
11781
|
+
|
|
11782
|
+
// ../api-routes/src/doctor.ts
|
|
11783
|
+
function parseCheckIds(raw) {
|
|
11784
|
+
if (!raw) return [];
|
|
11785
|
+
return raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
11786
|
+
}
|
|
11787
|
+
function resolveRedirectUri(opts) {
|
|
11788
|
+
if (!opts.publicUrl) return void 0;
|
|
11789
|
+
return `${opts.publicUrl.replace(/\/$/, "")}/api/v1/google/callback`;
|
|
11790
|
+
}
|
|
11791
|
+
async function doctorRoutes(app, opts) {
|
|
11792
|
+
const redirectUri = resolveRedirectUri(opts);
|
|
11793
|
+
app.get("/doctor", async (request) => {
|
|
11794
|
+
const checkIds = parseCheckIds(request.query.check);
|
|
11795
|
+
const ctx = {
|
|
11796
|
+
db: app.db,
|
|
11797
|
+
project: null,
|
|
11798
|
+
googleConnectionStore: opts.googleConnectionStore,
|
|
11799
|
+
ga4CredentialStore: opts.ga4CredentialStore,
|
|
11800
|
+
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
11801
|
+
redirectUri,
|
|
11802
|
+
providerSummary: opts.providerSummary
|
|
11803
|
+
};
|
|
11804
|
+
return runChecks(ctx, ALL_CHECKS, { checkIds });
|
|
11805
|
+
});
|
|
11806
|
+
app.get("/projects/:name/doctor", async (request) => {
|
|
11807
|
+
const project = resolveProject(app.db, request.params.name);
|
|
11808
|
+
const checkIds = parseCheckIds(request.query.check);
|
|
11809
|
+
const ctx = {
|
|
11810
|
+
db: app.db,
|
|
11811
|
+
project: {
|
|
11812
|
+
id: project.id,
|
|
11813
|
+
name: project.name,
|
|
11814
|
+
canonicalDomain: project.canonicalDomain,
|
|
11815
|
+
displayName: project.displayName
|
|
11816
|
+
},
|
|
11817
|
+
googleConnectionStore: opts.googleConnectionStore,
|
|
11818
|
+
ga4CredentialStore: opts.ga4CredentialStore,
|
|
11819
|
+
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
11820
|
+
redirectUri,
|
|
11821
|
+
providerSummary: opts.providerSummary
|
|
11822
|
+
};
|
|
11823
|
+
return runChecks(ctx, ALL_CHECKS, { checkIds });
|
|
11824
|
+
});
|
|
11825
|
+
}
|
|
11826
|
+
|
|
11196
11827
|
// ../api-routes/src/index.ts
|
|
11197
11828
|
async function apiRoutes(app, opts) {
|
|
11198
11829
|
app.decorate("db", opts.db);
|
|
@@ -11312,6 +11943,13 @@ async function apiRoutes(app, opts) {
|
|
|
11312
11943
|
onBacklinksPruneCache: opts.onBacklinksPruneCache,
|
|
11313
11944
|
listCachedReleases: opts.listCachedReleases
|
|
11314
11945
|
});
|
|
11946
|
+
await api.register(doctorRoutes, {
|
|
11947
|
+
googleConnectionStore: opts.googleConnectionStore,
|
|
11948
|
+
ga4CredentialStore: opts.ga4CredentialStore,
|
|
11949
|
+
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
11950
|
+
publicUrl: opts.publicUrl,
|
|
11951
|
+
providerSummary: opts.providerSummary
|
|
11952
|
+
});
|
|
11315
11953
|
if (opts.registerAuthenticatedRoutes) {
|
|
11316
11954
|
await opts.registerAuthenticatedRoutes(api);
|
|
11317
11955
|
}
|