@ainyc/canonry 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,7 +151,7 @@ function trackEvent(event, properties) {
151
151
 
152
152
  // src/server.ts
153
153
  import { createRequire as createRequire2 } from "module";
154
- import crypto16 from "crypto";
154
+ import crypto17 from "crypto";
155
155
  import fs2 from "fs";
156
156
  import path2 from "path";
157
157
  import { fileURLToPath } from "url";
@@ -806,6 +806,25 @@ var gscUrlInspectionDtoSchema = z4.object({
806
806
  inspectedAt: z4.string()
807
807
  });
808
808
  var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
809
+ var gscDeindexedRowSchema = z4.object({
810
+ url: z4.string(),
811
+ previousState: z4.string().nullable(),
812
+ currentState: z4.string().nullable(),
813
+ transitionDate: z4.string()
814
+ });
815
+ var gscCoverageSummaryDtoSchema = z4.object({
816
+ summary: z4.object({
817
+ total: z4.number(),
818
+ indexed: z4.number(),
819
+ notIndexed: z4.number(),
820
+ deindexed: z4.number(),
821
+ percentage: z4.number()
822
+ }),
823
+ lastInspectedAt: z4.string().nullable(),
824
+ indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
825
+ notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
826
+ deindexed: z4.array(gscDeindexedRowSchema).default([])
827
+ });
809
828
 
810
829
  // ../contracts/src/project.ts
811
830
  import { z as z5 } from "zod";
@@ -853,7 +872,7 @@ function effectiveDomains(project) {
853
872
  // ../contracts/src/run.ts
854
873
  import { z as z6 } from "zod";
855
874
  var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
856
- var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync"]);
875
+ var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
857
876
  var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
858
877
  var citationStateSchema = z6.enum(["cited", "not-cited"]);
859
878
  var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
@@ -1193,6 +1212,34 @@ async function keywordRoutes(app, opts) {
1193
1212
  const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1194
1213
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
1195
1214
  });
1215
+ app.delete("/projects/:name/keywords", async (request, reply) => {
1216
+ const project = resolveProjectSafe(app, request.params.name, reply);
1217
+ if (!project) return;
1218
+ const body = request.body;
1219
+ if (!body || !Array.isArray(body.keywords) || body.keywords.length === 0) {
1220
+ const err = validationError('Body must contain a non-empty "keywords" array');
1221
+ return reply.status(err.statusCode).send(err.toJSON());
1222
+ }
1223
+ const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1224
+ const toDelete = new Set(body.keywords);
1225
+ const idsToDelete = existing.filter((k) => toDelete.has(k.keyword)).map((k) => k.id);
1226
+ if (idsToDelete.length > 0) {
1227
+ app.db.transaction((tx) => {
1228
+ for (const id of idsToDelete) {
1229
+ tx.delete(keywords).where(eq4(keywords.id, id)).run();
1230
+ }
1231
+ writeAuditLog(tx, {
1232
+ projectId: project.id,
1233
+ actor: "api",
1234
+ action: "keywords.deleted",
1235
+ entityType: "keyword",
1236
+ diff: { deleted: body.keywords.filter((kw) => existing.some((e) => e.keyword === kw)) }
1237
+ });
1238
+ });
1239
+ }
1240
+ const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1241
+ return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
1242
+ });
1196
1243
  app.post("/projects/:name/keywords", async (request, reply) => {
1197
1244
  const project = resolveProjectSafe(app, request.params.name, reply);
1198
1245
  if (!project) return;
@@ -3507,34 +3554,63 @@ async function googleRoutes(app, opts) {
3507
3554
  const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
3508
3555
  return reply.status(err.statusCode).send(err.toJSON());
3509
3556
  }
3510
- const { type, propertyId } = request.body ?? {};
3557
+ const { type, propertyId, publicUrl } = request.body ?? {};
3511
3558
  if (!type || type !== "gsc" && type !== "ga4") {
3512
3559
  const err = validationError('type must be "gsc" or "ga4"');
3513
3560
  return reply.status(err.statusCode).send(err.toJSON());
3514
3561
  }
3515
3562
  const project = resolveProject(app.db, request.params.name);
3516
- const proto = request.headers["x-forwarded-proto"] ?? "http";
3517
- const host = request.headers.host ?? "localhost:4100";
3518
- const redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
3563
+ let redirectUri;
3564
+ if (publicUrl) {
3565
+ redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
3566
+ } else if (opts.publicUrl) {
3567
+ redirectUri = opts.publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
3568
+ } else {
3569
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
3570
+ const host = request.headers.host ?? "localhost:4100";
3571
+ redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
3572
+ }
3519
3573
  const scopes = type === "gsc" ? [GSC_SCOPE] : [];
3520
3574
  const stateEncoded = buildSignedState(
3521
3575
  { domain: project.canonicalDomain, type, propertyId, redirectUri },
3522
3576
  stateSecret
3523
3577
  );
3524
3578
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
3525
- return { authUrl };
3579
+ return { authUrl, redirectUri };
3526
3580
  });
3527
- app.get("/projects/:name/google/callback", async (request, reply) => {
3581
+ async function handleOAuthCallback(request, reply) {
3528
3582
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3529
3583
  if (!googleClientId || !googleClientSecret) {
3530
3584
  return reply.status(500).send("Google OAuth not configured");
3531
3585
  }
3532
3586
  const store = requireConnectionStore(reply);
3533
3587
  if (!store) return;
3588
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3534
3589
  const { code, state, error } = request.query;
3535
3590
  if (error) {
3536
- const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3537
- return reply.type("text/html").send(`<html><body><h2>Authorization failed</h2><p>${safeError}</p><p>You can close this tab.</p></body></html>`);
3591
+ const safeError = escapeHtml(String(error));
3592
+ const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
3593
+ <h2 style="color:#ef4444">Redirect URI mismatch</h2>
3594
+ <p>Google rejected the OAuth callback because the redirect URI is not registered.</p>
3595
+ <p><strong>To fix this:</strong></p>
3596
+ <ol>
3597
+ <li>Go to the <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console \u2192 Credentials</a></li>
3598
+ <li>Click your OAuth 2.0 Client ID</li>
3599
+ <li>Under "Authorized redirect URIs", add:<br><code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px;display:inline-block;margin-top:4px">${request.query.state ? (() => {
3600
+ try {
3601
+ const s = verifySignedState(request.query.state, stateSecret);
3602
+ return escapeHtml(String(s?.redirectUri ?? "Could not determine URI"));
3603
+ } catch {
3604
+ return "Could not determine URI";
3605
+ }
3606
+ })() : "Could not determine URI"}</code></li>
3607
+ <li>Click Save, then retry the connection</li>
3608
+ </ol>
3609
+ <p style="color:#888">You can close this tab.</p>
3610
+ </body></html>` : `<html><body style="font-family:system-ui;text-align:center;padding:60px">
3611
+ <h2>Authorization failed</h2><p>${safeError}</p><p style="color:#888">You can close this tab.</p>
3612
+ </body></html>`;
3613
+ return reply.type("text/html").send(errorHtml);
3538
3614
  }
3539
3615
  if (!code || !state) {
3540
3616
  return reply.status(400).send("Missing code or state parameter");
@@ -3544,7 +3620,23 @@ async function googleRoutes(app, opts) {
3544
3620
  return reply.status(400).send("Invalid or tampered state parameter");
3545
3621
  }
3546
3622
  const { domain, type, propertyId, redirectUri } = stateData;
3547
- const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3623
+ let tokens;
3624
+ try {
3625
+ tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3626
+ } catch (err) {
3627
+ const msg = err instanceof Error ? err.message : String(err);
3628
+ return reply.type("text/html").send(
3629
+ `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
3630
+ <h2 style="color:#ef4444">Token exchange failed</h2>
3631
+ <p>${escapeHtml(msg)}</p>
3632
+ <p><strong>Redirect URI used:</strong><br>
3633
+ <code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px">${escapeHtml(redirectUri)}</code>
3634
+ </p>
3635
+ <p>Ensure this URI is listed in your <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> OAuth client's authorized redirect URIs.</p>
3636
+ <p style="color:#888">You can close this tab.</p>
3637
+ </body></html>`
3638
+ );
3639
+ }
3548
3640
  const now = (/* @__PURE__ */ new Date()).toISOString();
3549
3641
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3550
3642
  const existing = store.getConnection(domain, type);
@@ -3574,6 +3666,12 @@ async function googleRoutes(app, opts) {
3574
3666
  <p style="color:#888">You can close this tab.</p>
3575
3667
  </body></html>`
3576
3668
  );
3669
+ }
3670
+ app.get("/google/callback", async (request, reply) => {
3671
+ return handleOAuthCallback(request, reply);
3672
+ });
3673
+ app.get("/projects/:name/google/callback", async (request, reply) => {
3674
+ return handleOAuthCallback(request, reply);
3577
3675
  });
3578
3676
  app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
3579
3677
  const store = requireConnectionStore(reply);
@@ -3752,7 +3850,7 @@ async function googleRoutes(app, opts) {
3752
3850
  if (inspections.length < 2) continue;
3753
3851
  const latest = inspections[0];
3754
3852
  const previous = inspections[1];
3755
- if (previous.indexingState?.toUpperCase() === "INDEXED" && latest.indexingState?.toUpperCase() !== "INDEXED") {
3853
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
3756
3854
  deindexed.push({
3757
3855
  url,
3758
3856
  previousState: previous.indexingState,
@@ -3763,6 +3861,110 @@ async function googleRoutes(app, opts) {
3763
3861
  }
3764
3862
  return deindexed;
3765
3863
  });
3864
+ app.get("/projects/:name/google/gsc/coverage", async (request) => {
3865
+ const project = resolveProject(app.db, request.params.name);
3866
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq12(gscUrlInspections.projectId, project.id)).orderBy(desc2(gscUrlInspections.inspectedAt)).all();
3867
+ const latestByUrl = /* @__PURE__ */ new Map();
3868
+ const historyByUrl = /* @__PURE__ */ new Map();
3869
+ for (const row of allInspections) {
3870
+ if (!latestByUrl.has(row.url)) {
3871
+ latestByUrl.set(row.url, row);
3872
+ }
3873
+ const history = historyByUrl.get(row.url);
3874
+ if (history) {
3875
+ history.push(row);
3876
+ } else {
3877
+ historyByUrl.set(row.url, [row]);
3878
+ }
3879
+ }
3880
+ const indexedUrls = [];
3881
+ const notIndexedUrls = [];
3882
+ let lastInspectedAt = null;
3883
+ for (const [, row] of latestByUrl) {
3884
+ if (row.indexingState === "INDEXING_ALLOWED") {
3885
+ indexedUrls.push(row);
3886
+ } else {
3887
+ notIndexedUrls.push(row);
3888
+ }
3889
+ if (!lastInspectedAt || row.inspectedAt > lastInspectedAt) {
3890
+ lastInspectedAt = row.inspectedAt;
3891
+ }
3892
+ }
3893
+ const deindexedUrls = [];
3894
+ for (const [url, history] of historyByUrl) {
3895
+ if (history.length < 2) continue;
3896
+ const latest = history[0];
3897
+ const previous = history[1];
3898
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
3899
+ deindexedUrls.push({
3900
+ url,
3901
+ previousState: previous.indexingState,
3902
+ currentState: latest.indexingState,
3903
+ transitionDate: latest.inspectedAt
3904
+ });
3905
+ }
3906
+ }
3907
+ const total = latestByUrl.size;
3908
+ const indexed = indexedUrls.length;
3909
+ const notIndexed = notIndexedUrls.length;
3910
+ const formatRow = (r) => ({
3911
+ id: r.id,
3912
+ url: r.url,
3913
+ indexingState: r.indexingState,
3914
+ verdict: r.verdict,
3915
+ coverageState: r.coverageState,
3916
+ pageFetchState: r.pageFetchState,
3917
+ robotsTxtState: r.robotsTxtState,
3918
+ crawlTime: r.crawlTime,
3919
+ lastCrawlResult: r.lastCrawlResult,
3920
+ isMobileFriendly: r.isMobileFriendly === 1 ? true : r.isMobileFriendly === 0 ? false : null,
3921
+ richResults: JSON.parse(r.richResults),
3922
+ inspectedAt: r.inspectedAt
3923
+ });
3924
+ return {
3925
+ summary: {
3926
+ total,
3927
+ indexed,
3928
+ notIndexed,
3929
+ deindexed: deindexedUrls.length,
3930
+ percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
3931
+ },
3932
+ lastInspectedAt,
3933
+ indexed: indexedUrls.map(formatRow),
3934
+ notIndexed: notIndexedUrls.map(formatRow),
3935
+ deindexed: deindexedUrls
3936
+ };
3937
+ });
3938
+ app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
3939
+ const store = requireConnectionStore(reply);
3940
+ if (!store) return;
3941
+ const project = resolveProject(app.db, request.params.name);
3942
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
3943
+ if (!conn) {
3944
+ const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
3945
+ return reply.status(err.statusCode).send(err.toJSON());
3946
+ }
3947
+ if (!conn.propertyId) {
3948
+ const err = validationError("No GSC property configured for this connection");
3949
+ return reply.status(err.statusCode).send(err.toJSON());
3950
+ }
3951
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3952
+ const runId = crypto12.randomUUID();
3953
+ app.db.insert(runs).values({
3954
+ id: runId,
3955
+ projectId: project.id,
3956
+ kind: "inspect-sitemap",
3957
+ status: "queued",
3958
+ trigger: "manual",
3959
+ createdAt: now
3960
+ }).run();
3961
+ const { sitemapUrl } = request.body ?? {};
3962
+ if (opts.onInspectSitemapRequested) {
3963
+ opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
3964
+ }
3965
+ const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
3966
+ return run;
3967
+ });
3766
3968
  app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3767
3969
  const store = requireConnectionStore(reply);
3768
3970
  if (!store) return;
@@ -3829,7 +4031,9 @@ async function apiRoutes(app, opts) {
3829
4031
  getGoogleAuthConfig: opts.getGoogleAuthConfig,
3830
4032
  googleConnectionStore: opts.googleConnectionStore,
3831
4033
  googleStateSecret: opts.googleStateSecret,
3832
- onGscSyncRequested: opts.onGscSyncRequested
4034
+ publicUrl: opts.publicUrl,
4035
+ onGscSyncRequested: opts.onGscSyncRequested,
4036
+ onInspectSitemapRequested: opts.onInspectSitemapRequested
3833
4037
  });
3834
4038
  }, { prefix: "/api/v1" });
3835
4039
  }
@@ -5213,6 +5417,161 @@ async function executeGscSync(db, runId, projectId, opts) {
5213
5417
  }
5214
5418
  }
5215
5419
 
5420
+ // src/gsc-inspect-sitemap.ts
5421
+ import crypto15 from "crypto";
5422
+ import { eq as eq15 } from "drizzle-orm";
5423
+
5424
+ // src/sitemap-parser.ts
5425
+ var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
5426
+ var SITEMAP_TAG_REGEX = /<sitemap>[\s\S]*?<\/sitemap>/gi;
5427
+ var PRIVATE_IP_PATTERNS = [
5428
+ /^169\.254\./,
5429
+ // link-local (AWS metadata endpoint etc.)
5430
+ /^10\./,
5431
+ // private class A
5432
+ /^172\.(1[6-9]|2\d|3[01])\./,
5433
+ // private class B
5434
+ /^192\.168\./
5435
+ // private class C
5436
+ ];
5437
+ function validateSitemapUrl(url) {
5438
+ let parsed;
5439
+ try {
5440
+ parsed = new URL(url);
5441
+ } catch {
5442
+ throw new Error(`Invalid sitemap URL: ${url}`);
5443
+ }
5444
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
5445
+ throw new Error(`Sitemap URL must use http or https protocol: ${url}`);
5446
+ }
5447
+ const host = parsed.hostname.toLowerCase();
5448
+ for (const pattern of PRIVATE_IP_PATTERNS) {
5449
+ if (pattern.test(host)) {
5450
+ throw new Error(`Sitemap URL points to a private or reserved IP range: ${url}`);
5451
+ }
5452
+ }
5453
+ }
5454
+ async function fetchAndParseSitemap(sitemapUrl) {
5455
+ const urls = /* @__PURE__ */ new Set();
5456
+ await parseSitemapRecursive(sitemapUrl, urls, 0);
5457
+ return [...urls];
5458
+ }
5459
+ async function parseSitemapRecursive(url, urls, depth) {
5460
+ if (depth > 3) return;
5461
+ validateSitemapUrl(url);
5462
+ const res = await fetch(url);
5463
+ if (!res.ok) {
5464
+ throw new Error(`Failed to fetch sitemap at ${url}: ${res.status} ${res.statusText}`);
5465
+ }
5466
+ const xml = await res.text();
5467
+ const sitemapEntries = xml.match(SITEMAP_TAG_REGEX);
5468
+ if (sitemapEntries) {
5469
+ for (const entry of sitemapEntries) {
5470
+ const locMatch = LOC_REGEX.exec(entry);
5471
+ LOC_REGEX.lastIndex = 0;
5472
+ if (locMatch?.[1]) {
5473
+ await parseSitemapRecursive(locMatch[1], urls, depth + 1);
5474
+ }
5475
+ }
5476
+ return;
5477
+ }
5478
+ let match;
5479
+ while ((match = LOC_REGEX.exec(xml)) !== null) {
5480
+ if (match[1]) {
5481
+ urls.add(match[1]);
5482
+ }
5483
+ }
5484
+ LOC_REGEX.lastIndex = 0;
5485
+ }
5486
+
5487
+ // src/gsc-inspect-sitemap.ts
5488
+ async function executeInspectSitemap(db, runId, projectId, opts) {
5489
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5490
+ db.update(runs).set({ status: "running", startedAt: now }).where(eq15(runs.id, runId)).run();
5491
+ try {
5492
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5493
+ if (!googleClientId || !googleClientSecret) {
5494
+ throw new Error("Google OAuth is not configured in the local Canonry config");
5495
+ }
5496
+ const project = db.select().from(projects).where(eq15(projects.id, projectId)).get();
5497
+ if (!project) {
5498
+ throw new Error(`Project not found: ${projectId}`);
5499
+ }
5500
+ const conn = getGoogleConnection(opts.config, project.canonicalDomain, "gsc");
5501
+ if (!conn || !conn.refreshToken) {
5502
+ throw new Error("No GSC connection found or connection is incomplete");
5503
+ }
5504
+ if (!conn.propertyId) {
5505
+ throw new Error('No GSC property selected. Use "canonry google properties" to list available sites, then set one.');
5506
+ }
5507
+ let accessToken = conn.accessToken;
5508
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
5509
+ if (Date.now() > expiresAt - 5 * 60 * 1e3) {
5510
+ const tokens = await refreshAccessToken(googleClientId, googleClientSecret, conn.refreshToken);
5511
+ accessToken = tokens.access_token;
5512
+ patchGoogleConnection(opts.config, project.canonicalDomain, "gsc", {
5513
+ accessToken: tokens.access_token,
5514
+ tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
5515
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5516
+ });
5517
+ saveConfig(opts.config);
5518
+ }
5519
+ const sitemapUrl = opts.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
5520
+ console.log(`[Inspect Sitemap] Fetching sitemap from ${sitemapUrl}`);
5521
+ const urls = await fetchAndParseSitemap(sitemapUrl);
5522
+ console.log(`[Inspect Sitemap] Found ${urls.length} URLs in sitemap`);
5523
+ if (urls.length === 0) {
5524
+ throw new Error("No URLs found in sitemap");
5525
+ }
5526
+ let inspected = 0;
5527
+ let errors = 0;
5528
+ for (const pageUrl of urls) {
5529
+ try {
5530
+ const result = await inspectUrl(accessToken, pageUrl, conn.propertyId);
5531
+ const ir = result.inspectionResult;
5532
+ const idx = ir.indexStatusResult;
5533
+ const mob = ir.mobileUsabilityResult;
5534
+ const rich = ir.richResultsResult;
5535
+ const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5536
+ db.insert(gscUrlInspections).values({
5537
+ id: crypto15.randomUUID(),
5538
+ projectId,
5539
+ syncRunId: runId,
5540
+ url: pageUrl,
5541
+ indexingState: idx?.indexingState ?? null,
5542
+ verdict: idx?.verdict ?? null,
5543
+ coverageState: idx?.coverageState ?? null,
5544
+ pageFetchState: idx?.pageFetchState ?? null,
5545
+ robotsTxtState: idx?.robotsTxtState ?? null,
5546
+ crawlTime: idx?.lastCrawlTime ?? null,
5547
+ lastCrawlResult: idx?.crawlResult ?? null,
5548
+ isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
5549
+ richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
5550
+ referringUrls: JSON.stringify(idx?.referringUrls ?? []),
5551
+ inspectedAt,
5552
+ createdAt: inspectedAt
5553
+ }).run();
5554
+ inspected++;
5555
+ console.log(`[Inspect Sitemap] ${inspected}/${urls.length} inspected: ${pageUrl}`);
5556
+ } catch (err) {
5557
+ errors++;
5558
+ console.error(`[Inspect Sitemap] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
5559
+ }
5560
+ if (inspected + errors < urls.length) {
5561
+ await new Promise((r) => setTimeout(r, 1e3));
5562
+ }
5563
+ }
5564
+ const status = errors > 0 && inspected > 0 ? "partial" : errors === urls.length ? "failed" : "completed";
5565
+ db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
5566
+ console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs.`);
5567
+ } catch (err) {
5568
+ const errorMsg = err instanceof Error ? err.message : String(err);
5569
+ db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
5570
+ console.error(`[Inspect Sitemap] Failed:`, errorMsg);
5571
+ throw err;
5572
+ }
5573
+ }
5574
+
5216
5575
  // src/provider-registry.ts
5217
5576
  var ProviderRegistry = class {
5218
5577
  providers = /* @__PURE__ */ new Map();
@@ -5258,7 +5617,7 @@ var ProviderRegistry = class {
5258
5617
 
5259
5618
  // src/scheduler.ts
5260
5619
  import cron from "node-cron";
5261
- import { eq as eq15 } from "drizzle-orm";
5620
+ import { eq as eq16 } from "drizzle-orm";
5262
5621
  var Scheduler = class {
5263
5622
  db;
5264
5623
  callbacks;
@@ -5269,7 +5628,7 @@ var Scheduler = class {
5269
5628
  }
5270
5629
  /** Load all enabled schedules from DB and register cron jobs. */
5271
5630
  start() {
5272
- const allSchedules = this.db.select().from(schedules).where(eq15(schedules.enabled, 1)).all();
5631
+ const allSchedules = this.db.select().from(schedules).where(eq16(schedules.enabled, 1)).all();
5273
5632
  for (const schedule of allSchedules) {
5274
5633
  const missedRunAt = schedule.nextRunAt;
5275
5634
  this.registerCronTask(schedule);
@@ -5294,7 +5653,7 @@ var Scheduler = class {
5294
5653
  this.stopTask(projectId, existing, "Stopped");
5295
5654
  this.tasks.delete(projectId);
5296
5655
  }
5297
- const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
5656
+ const schedule = this.db.select().from(schedules).where(eq16(schedules.projectId, projectId)).get();
5298
5657
  if (schedule && schedule.enabled === 1) {
5299
5658
  this.registerCronTask(schedule);
5300
5659
  }
@@ -5327,13 +5686,13 @@ var Scheduler = class {
5327
5686
  this.db.update(schedules).set({
5328
5687
  nextRunAt: task.getNextRun()?.toISOString() ?? null,
5329
5688
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5330
- }).where(eq15(schedules.id, scheduleId)).run();
5689
+ }).where(eq16(schedules.id, scheduleId)).run();
5331
5690
  const label = schedule.preset ?? cronExpr;
5332
5691
  console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
5333
5692
  }
5334
5693
  triggerRun(scheduleId, projectId) {
5335
5694
  const now = (/* @__PURE__ */ new Date()).toISOString();
5336
- const currentSchedule = this.db.select().from(schedules).where(eq15(schedules.id, scheduleId)).get();
5695
+ const currentSchedule = this.db.select().from(schedules).where(eq16(schedules.id, scheduleId)).get();
5337
5696
  if (!currentSchedule || currentSchedule.enabled !== 1) {
5338
5697
  console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
5339
5698
  this.remove(projectId);
@@ -5341,7 +5700,7 @@ var Scheduler = class {
5341
5700
  }
5342
5701
  const task = this.tasks.get(projectId);
5343
5702
  const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
5344
- const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
5703
+ const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
5345
5704
  if (!project) {
5346
5705
  console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
5347
5706
  this.remove(projectId);
@@ -5358,7 +5717,7 @@ var Scheduler = class {
5358
5717
  this.db.update(schedules).set({
5359
5718
  nextRunAt,
5360
5719
  updatedAt: now
5361
- }).where(eq15(schedules.id, currentSchedule.id)).run();
5720
+ }).where(eq16(schedules.id, currentSchedule.id)).run();
5362
5721
  return;
5363
5722
  }
5364
5723
  const runId = queueResult.runId;
@@ -5366,7 +5725,7 @@ var Scheduler = class {
5366
5725
  lastRunAt: now,
5367
5726
  nextRunAt,
5368
5727
  updatedAt: now
5369
- }).where(eq15(schedules.id, currentSchedule.id)).run();
5728
+ }).where(eq16(schedules.id, currentSchedule.id)).run();
5370
5729
  const scheduleProviders = JSON.parse(currentSchedule.providers);
5371
5730
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
5372
5731
  console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
@@ -5375,8 +5734,8 @@ var Scheduler = class {
5375
5734
  };
5376
5735
 
5377
5736
  // src/notifier.ts
5378
- import { eq as eq16, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5379
- import crypto15 from "crypto";
5737
+ import { eq as eq17, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5738
+ import crypto16 from "crypto";
5380
5739
  var Notifier = class {
5381
5740
  db;
5382
5741
  serverUrl;
@@ -5387,18 +5746,18 @@ var Notifier = class {
5387
5746
  /** Called after a run completes (success, partial, or failed). */
5388
5747
  async onRunCompleted(runId, projectId) {
5389
5748
  console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
5390
- const notifs = this.db.select().from(notifications).where(eq16(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
5749
+ const notifs = this.db.select().from(notifications).where(eq17(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
5391
5750
  if (notifs.length === 0) {
5392
5751
  console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
5393
5752
  return;
5394
5753
  }
5395
5754
  console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
5396
- const run = this.db.select().from(runs).where(eq16(runs.id, runId)).get();
5755
+ const run = this.db.select().from(runs).where(eq17(runs.id, runId)).get();
5397
5756
  if (!run) {
5398
5757
  console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
5399
5758
  return;
5400
5759
  }
5401
- const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
5760
+ const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
5402
5761
  if (!project) {
5403
5762
  console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
5404
5763
  return;
@@ -5439,8 +5798,8 @@ var Notifier = class {
5439
5798
  computeTransitions(runId, projectId) {
5440
5799
  const recentRuns = this.db.select().from(runs).where(
5441
5800
  and5(
5442
- eq16(runs.projectId, projectId),
5443
- or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
5801
+ eq17(runs.projectId, projectId),
5802
+ or2(eq17(runs.status, "completed"), eq17(runs.status, "partial"))
5444
5803
  )
5445
5804
  ).orderBy(desc3(runs.createdAt)).limit(2).all();
5446
5805
  if (recentRuns.length < 2) return [];
@@ -5452,12 +5811,12 @@ var Notifier = class {
5452
5811
  keyword: keywords.keyword,
5453
5812
  provider: querySnapshots.provider,
5454
5813
  citationState: querySnapshots.citationState
5455
- }).from(querySnapshots).leftJoin(keywords, eq16(querySnapshots.keywordId, keywords.id)).where(eq16(querySnapshots.runId, currentRunId)).all();
5814
+ }).from(querySnapshots).leftJoin(keywords, eq17(querySnapshots.keywordId, keywords.id)).where(eq17(querySnapshots.runId, currentRunId)).all();
5456
5815
  const previousSnapshots = this.db.select({
5457
5816
  keywordId: querySnapshots.keywordId,
5458
5817
  provider: querySnapshots.provider,
5459
5818
  citationState: querySnapshots.citationState
5460
- }).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
5819
+ }).from(querySnapshots).where(eq17(querySnapshots.runId, previousRunId)).all();
5461
5820
  const prevMap = /* @__PURE__ */ new Map();
5462
5821
  for (const s of previousSnapshots) {
5463
5822
  prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
@@ -5514,7 +5873,7 @@ var Notifier = class {
5514
5873
  }
5515
5874
  logDelivery(projectId, notificationId, event, status, error) {
5516
5875
  this.db.insert(auditLog).values({
5517
- id: crypto15.randomUUID(),
5876
+ id: crypto16.randomUUID(),
5518
5877
  projectId,
5519
5878
  actor: "scheduler",
5520
5879
  action: `notification.${status}`,
@@ -5645,6 +6004,14 @@ var DEFAULT_QUOTA = {
5645
6004
  maxRequestsPerMinute: 10,
5646
6005
  maxRequestsPerDay: 1e3
5647
6006
  };
6007
+ function summarizeProviderConfig(provider, config) {
6008
+ return {
6009
+ configured: Boolean(config?.apiKey || config?.baseUrl),
6010
+ model: config?.model ?? null,
6011
+ baseUrl: provider === "local" ? config?.baseUrl ?? null : null,
6012
+ quota: { ...config?.quota ?? DEFAULT_QUOTA }
6013
+ };
6014
+ }
5648
6015
  async function createServer(opts) {
5649
6016
  const logger = opts.logger === false ? false : process.stdout.isTTY ? {
5650
6017
  transport: {
@@ -5729,7 +6096,7 @@ async function createServer(opts) {
5729
6096
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
5730
6097
  };
5731
6098
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
5732
- const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
6099
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto17.randomBytes(32).toString("hex");
5733
6100
  const googleConnectionStore = {
5734
6101
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
5735
6102
  getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
@@ -5755,6 +6122,7 @@ async function createServer(opts) {
5755
6122
  getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
5756
6123
  googleConnectionStore,
5757
6124
  googleStateSecret,
6125
+ publicUrl: opts.config.publicUrl,
5758
6126
  onGscSyncRequested: (runId, projectId, syncOpts) => {
5759
6127
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5760
6128
  if (!googleClientId || !googleClientSecret) {
@@ -5768,6 +6136,19 @@ async function createServer(opts) {
5768
6136
  app.log.error({ runId, err }, "GSC sync failed");
5769
6137
  });
5770
6138
  },
6139
+ onInspectSitemapRequested: (runId, projectId, inspectOpts) => {
6140
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
6141
+ if (!googleClientId || !googleClientSecret) {
6142
+ app.log.error("Inspect sitemap requested but Google OAuth credentials are not configured");
6143
+ return;
6144
+ }
6145
+ executeInspectSitemap(opts.db, runId, projectId, {
6146
+ ...inspectOpts,
6147
+ config: opts.config
6148
+ }).catch((err) => {
6149
+ app.log.error({ runId, err }, "Inspect sitemap failed");
6150
+ });
6151
+ },
5771
6152
  openApiInfo: {
5772
6153
  title: "Canonry API",
5773
6154
  version: PKG_VERSION
@@ -5784,6 +6165,7 @@ async function createServer(opts) {
5784
6165
  if (!(name in adapterMap)) return null;
5785
6166
  if (!opts.config.providers) opts.config.providers = {};
5786
6167
  const existing = opts.config.providers[name];
6168
+ const beforeConfig = summarizeProviderConfig(name, existing);
5787
6169
  const mergedQuota = incomingQuota ? { ...existing?.quota ?? DEFAULT_QUOTA, ...incomingQuota } : existing?.quota;
5788
6170
  opts.config.providers[name] = {
5789
6171
  apiKey: apiKey || existing?.apiKey,
@@ -5811,6 +6193,33 @@ async function createServer(opts) {
5811
6193
  entry.model = model || registry.get(name)?.config.model;
5812
6194
  entry.quota = quota;
5813
6195
  }
6196
+ const afterConfig = summarizeProviderConfig(name, opts.config.providers[name]);
6197
+ if (JSON.stringify(beforeConfig) !== JSON.stringify(afterConfig)) {
6198
+ const diff = JSON.stringify({
6199
+ before: existing ? beforeConfig : null,
6200
+ after: afterConfig
6201
+ });
6202
+ const affectedProjectIds = opts.db.select({ id: projects.id, providers: projects.providers }).from(projects).all().filter((project) => {
6203
+ try {
6204
+ const configuredProviders = JSON.parse(project.providers || "[]");
6205
+ return configuredProviders.length === 0 || configuredProviders.includes(name);
6206
+ } catch {
6207
+ return false;
6208
+ }
6209
+ }).map((project) => project.id);
6210
+ const targetProjectIds = affectedProjectIds.length > 0 ? affectedProjectIds : [null];
6211
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6212
+ opts.db.insert(auditLog).values(targetProjectIds.map((projectId) => ({
6213
+ id: crypto17.randomUUID(),
6214
+ projectId,
6215
+ actor: "api",
6216
+ action: existing ? "provider.updated" : "provider.created",
6217
+ entityType: "provider",
6218
+ entityId: name,
6219
+ diff,
6220
+ createdAt
6221
+ }))).run();
6222
+ }
5814
6223
  return {
5815
6224
  name,
5816
6225
  model: entry?.model,