@ainyc/canonry 1.10.1 → 1.12.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.
@@ -9,6 +9,16 @@ import fs from "fs";
9
9
  import path from "path";
10
10
  import os from "os";
11
11
  import { parse, stringify } from "yaml";
12
+ function normalizeGoogleConfig(config) {
13
+ if (!config.google) return;
14
+ config.google.connections = (config.google.connections ?? []).map((connection) => ({
15
+ ...connection,
16
+ propertyId: connection.propertyId ?? null,
17
+ refreshToken: connection.refreshToken ?? null,
18
+ tokenExpiresAt: connection.tokenExpiresAt ?? null,
19
+ scopes: connection.scopes ?? []
20
+ }));
21
+ }
12
22
  function getConfigDir() {
13
23
  const override = process.env.CANONRY_CONFIG_DIR?.trim();
14
24
  if (override) {
@@ -25,7 +35,7 @@ function loadConfig() {
25
35
  throw new Error(
26
36
  `Config not found at ${configPath}.
27
37
  Run "canonry init" to set up interactively, or "canonry init --gemini-key <key>" for non-interactive setup.
28
- For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY).`
38
+ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
29
39
  );
30
40
  }
31
41
  const raw = fs.readFileSync(configPath, "utf-8");
@@ -39,7 +49,7 @@ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API
39
49
  throw new Error(
40
50
  `Invalid config at ${configPath} \u2014 missing: ${missing}.
41
51
  These fields are auto-generated. Run "canonry init" (or "canonry init --gemini-key <key>" for non-interactive setup) to create a valid config.
42
- Do not write config.yaml by hand; use "canonry init" or "canonry bootstrap" instead.`
52
+ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "canonry bootstrap" instead.`
43
53
  );
44
54
  }
45
55
  if (parsed.geminiApiKey && !parsed.providers?.gemini) {
@@ -52,6 +62,7 @@ Do not write config.yaml by hand; use "canonry init" or "canonry bootstrap" inst
52
62
  }
53
63
  };
54
64
  }
65
+ normalizeGoogleConfig(parsed);
55
66
  return parsed;
56
67
  }
57
68
  function saveConfig(config) {
@@ -1548,7 +1559,7 @@ function resolveProjectSafe3(app, name, reply) {
1548
1559
 
1549
1560
  // ../api-routes/src/apply.ts
1550
1561
  import crypto9 from "crypto";
1551
- import { eq as eq8, and as and3 } from "drizzle-orm";
1562
+ import { eq as eq8 } from "drizzle-orm";
1552
1563
 
1553
1564
  // ../api-routes/src/schedule-utils.ts
1554
1565
  var DAY_MAP = {
@@ -1986,11 +1997,7 @@ async function applyRoutes(app, opts) {
1986
1997
  });
1987
1998
  }
1988
1999
  if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
1989
- const domain = config.spec.canonicalDomain;
1990
- const conn = app.db.select().from(googleConnections).where(and3(eq8(googleConnections.domain, domain), eq8(googleConnections.connectionType, "gsc"))).get();
1991
- if (conn) {
1992
- app.db.update(googleConnections).set({ propertyId: config.spec.google.gsc.propertyUrl, updatedAt: now }).where(eq8(googleConnections.id, conn.id)).run();
1993
- }
2000
+ opts?.onGoogleConnectionPropertyUpdated?.(config.spec.canonicalDomain, "gsc", config.spec.google.gsc.propertyUrl);
1994
2001
  }
1995
2002
  const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
1996
2003
  return reply.status(200).send({
@@ -2887,7 +2894,8 @@ function buildOperationId(method, path3) {
2887
2894
  // ../api-routes/src/settings.ts
2888
2895
  async function settingsRoutes(app, opts) {
2889
2896
  app.get("/settings", async () => ({
2890
- providers: opts.providerSummary ?? []
2897
+ providers: opts.providerSummary ?? [],
2898
+ google: opts.google ?? { configured: false }
2891
2899
  }));
2892
2900
  app.put("/settings/providers/:name", async (request, reply) => {
2893
2901
  const providerName = parseProviderName(request.params.name);
@@ -2935,6 +2943,22 @@ async function settingsRoutes(app, opts) {
2935
2943
  }
2936
2944
  return result;
2937
2945
  });
2946
+ app.put("/settings/google", async (request, reply) => {
2947
+ const { clientId, clientSecret } = request.body ?? {};
2948
+ if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
2949
+ return reply.status(400).send({
2950
+ error: { code: "VALIDATION_ERROR", message: "clientId and clientSecret are required" }
2951
+ });
2952
+ }
2953
+ if (!opts.onGoogleUpdate) {
2954
+ return reply.status(501).send({ error: "Google OAuth configuration updates are not supported in this deployment" });
2955
+ }
2956
+ const result = opts.onGoogleUpdate(clientId, clientSecret);
2957
+ if (!result) {
2958
+ return reply.status(500).send({ error: "Failed to update Google OAuth configuration" });
2959
+ }
2960
+ return result;
2961
+ });
2938
2962
  }
2939
2963
 
2940
2964
  // ../api-routes/src/telemetry.ts
@@ -3248,7 +3272,7 @@ function resolveProjectSafe6(app, name, reply) {
3248
3272
 
3249
3273
  // ../api-routes/src/google.ts
3250
3274
  import crypto12 from "crypto";
3251
- import { eq as eq12, and as and4, desc as desc2, sql as sql2 } from "drizzle-orm";
3275
+ import { eq as eq12, and as and3, desc as desc2, sql as sql2 } from "drizzle-orm";
3252
3276
 
3253
3277
  // ../integration-google/src/constants.ts
3254
3278
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -3423,8 +3447,8 @@ function verifySignedState(encoded, secret) {
3423
3447
  return null;
3424
3448
  }
3425
3449
  }
3426
- async function getValidToken(app, domain, connectionType, clientId, clientSecret) {
3427
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, connectionType))).get();
3450
+ async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
3451
+ const conn = store.getConnection(domain, connectionType);
3428
3452
  if (!conn) {
3429
3453
  throw notFound("Google connection", connectionType);
3430
3454
  }
@@ -3436,64 +3460,110 @@ async function getValidToken(app, domain, connectionType, clientId, clientSecret
3436
3460
  if (Date.now() > expiresAt - fiveMinutes) {
3437
3461
  const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
3438
3462
  const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3439
- app.db.update(googleConnections).set({
3463
+ const updated = store.updateConnection(domain, connectionType, {
3440
3464
  accessToken: tokens.access_token,
3441
3465
  tokenExpiresAt: newExpiresAt,
3442
3466
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3443
- }).where(eq12(googleConnections.id, conn.id)).run();
3444
- return { accessToken: tokens.access_token, connectionId: conn.id, propertyId: conn.propertyId };
3467
+ });
3468
+ return {
3469
+ accessToken: tokens.access_token,
3470
+ connectionId: `${domain}:${connectionType}`,
3471
+ propertyId: updated?.propertyId ?? conn.propertyId ?? null
3472
+ };
3445
3473
  }
3446
- return { accessToken: conn.accessToken, connectionId: conn.id, propertyId: conn.propertyId };
3474
+ return {
3475
+ accessToken: conn.accessToken,
3476
+ connectionId: `${domain}:${connectionType}`,
3477
+ propertyId: conn.propertyId ?? null
3478
+ };
3447
3479
  }
3448
3480
  async function googleRoutes(app, opts) {
3449
- const { googleClientId, googleClientSecret } = opts;
3450
3481
  const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
3482
+ function getAuthConfig() {
3483
+ return opts.getGoogleAuthConfig?.() ?? {};
3484
+ }
3485
+ function requireConnectionStore(reply) {
3486
+ if (opts.googleConnectionStore) return opts.googleConnectionStore;
3487
+ const err = validationError("Google auth storage is not configured for this deployment");
3488
+ reply.status(err.statusCode).send(err.toJSON());
3489
+ return null;
3490
+ }
3451
3491
  app.get("/projects/:name/google/connections", async (request) => {
3452
3492
  const project = resolveProject(app.db, request.params.name);
3453
- const conns = app.db.select({
3454
- id: googleConnections.id,
3455
- domain: googleConnections.domain,
3456
- connectionType: googleConnections.connectionType,
3457
- propertyId: googleConnections.propertyId,
3458
- scopes: googleConnections.scopes,
3459
- createdAt: googleConnections.createdAt,
3460
- updatedAt: googleConnections.updatedAt
3461
- }).from(googleConnections).where(eq12(googleConnections.domain, project.canonicalDomain)).all();
3462
- return conns.map((c) => ({
3463
- ...c,
3464
- scopes: JSON.parse(c.scopes)
3493
+ const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
3494
+ return conns.map((connection) => ({
3495
+ id: `${connection.domain}:${connection.connectionType}`,
3496
+ domain: connection.domain,
3497
+ connectionType: connection.connectionType,
3498
+ propertyId: connection.propertyId ?? null,
3499
+ scopes: connection.scopes ?? [],
3500
+ createdAt: connection.createdAt,
3501
+ updatedAt: connection.updatedAt
3465
3502
  }));
3466
3503
  });
3467
3504
  app.post("/projects/:name/google/connect", async (request, reply) => {
3505
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3468
3506
  if (!googleClientId || !googleClientSecret) {
3469
- const err = validationError("Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.");
3507
+ const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
3470
3508
  return reply.status(err.statusCode).send(err.toJSON());
3471
3509
  }
3472
- const { type, propertyId } = request.body ?? {};
3510
+ const { type, propertyId, publicUrl } = request.body ?? {};
3473
3511
  if (!type || type !== "gsc" && type !== "ga4") {
3474
3512
  const err = validationError('type must be "gsc" or "ga4"');
3475
3513
  return reply.status(err.statusCode).send(err.toJSON());
3476
3514
  }
3477
3515
  const project = resolveProject(app.db, request.params.name);
3478
- const proto = request.headers["x-forwarded-proto"] ?? "http";
3479
- const host = request.headers.host ?? "localhost:4100";
3480
- const redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
3516
+ let redirectUri;
3517
+ if (publicUrl) {
3518
+ redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
3519
+ } else if (opts.publicUrl) {
3520
+ redirectUri = opts.publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
3521
+ } else {
3522
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
3523
+ const host = request.headers.host ?? "localhost:4100";
3524
+ redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
3525
+ }
3481
3526
  const scopes = type === "gsc" ? [GSC_SCOPE] : [];
3482
3527
  const stateEncoded = buildSignedState(
3483
3528
  { domain: project.canonicalDomain, type, propertyId, redirectUri },
3484
3529
  stateSecret
3485
3530
  );
3486
3531
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
3487
- return { authUrl };
3532
+ return { authUrl, redirectUri };
3488
3533
  });
3489
- app.get("/projects/:name/google/callback", async (request, reply) => {
3534
+ async function handleOAuthCallback(request, reply) {
3535
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3490
3536
  if (!googleClientId || !googleClientSecret) {
3491
3537
  return reply.status(500).send("Google OAuth not configured");
3492
3538
  }
3539
+ const store = requireConnectionStore(reply);
3540
+ if (!store) return;
3541
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3493
3542
  const { code, state, error } = request.query;
3494
3543
  if (error) {
3495
- const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3496
- return reply.type("text/html").send(`<html><body><h2>Authorization failed</h2><p>${safeError}</p><p>You can close this tab.</p></body></html>`);
3544
+ const safeError = escapeHtml(String(error));
3545
+ const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
3546
+ <h2 style="color:#ef4444">Redirect URI mismatch</h2>
3547
+ <p>Google rejected the OAuth callback because the redirect URI is not registered.</p>
3548
+ <p><strong>To fix this:</strong></p>
3549
+ <ol>
3550
+ <li>Go to the <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console \u2192 Credentials</a></li>
3551
+ <li>Click your OAuth 2.0 Client ID</li>
3552
+ <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 ? (() => {
3553
+ try {
3554
+ const s = verifySignedState(request.query.state, stateSecret);
3555
+ return escapeHtml(String(s?.redirectUri ?? "Could not determine URI"));
3556
+ } catch {
3557
+ return "Could not determine URI";
3558
+ }
3559
+ })() : "Could not determine URI"}</code></li>
3560
+ <li>Click Save, then retry the connection</li>
3561
+ </ol>
3562
+ <p style="color:#888">You can close this tab.</p>
3563
+ </body></html>` : `<html><body style="font-family:system-ui;text-align:center;padding:60px">
3564
+ <h2>Authorization failed</h2><p>${safeError}</p><p style="color:#888">You can close this tab.</p>
3565
+ </body></html>`;
3566
+ return reply.type("text/html").send(errorHtml);
3497
3567
  }
3498
3568
  if (!code || !state) {
3499
3569
  return reply.status(400).send("Missing code or state parameter");
@@ -3503,33 +3573,37 @@ async function googleRoutes(app, opts) {
3503
3573
  return reply.status(400).send("Invalid or tampered state parameter");
3504
3574
  }
3505
3575
  const { domain, type, propertyId, redirectUri } = stateData;
3506
- const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3576
+ let tokens;
3577
+ try {
3578
+ tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3579
+ } catch (err) {
3580
+ const msg = err instanceof Error ? err.message : String(err);
3581
+ return reply.type("text/html").send(
3582
+ `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
3583
+ <h2 style="color:#ef4444">Token exchange failed</h2>
3584
+ <p>${escapeHtml(msg)}</p>
3585
+ <p><strong>Redirect URI used:</strong><br>
3586
+ <code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px">${escapeHtml(redirectUri)}</code>
3587
+ </p>
3588
+ <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>
3589
+ <p style="color:#888">You can close this tab.</p>
3590
+ </body></html>`
3591
+ );
3592
+ }
3507
3593
  const now = (/* @__PURE__ */ new Date()).toISOString();
3508
3594
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3509
- const existing = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, type))).get();
3510
- if (existing) {
3511
- app.db.update(googleConnections).set({
3512
- accessToken: tokens.access_token,
3513
- refreshToken: tokens.refresh_token ?? existing.refreshToken,
3514
- tokenExpiresAt: expiresAt,
3515
- propertyId: propertyId ?? existing.propertyId,
3516
- scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3517
- updatedAt: now
3518
- }).where(eq12(googleConnections.id, existing.id)).run();
3519
- } else {
3520
- app.db.insert(googleConnections).values({
3521
- id: crypto12.randomUUID(),
3522
- domain,
3523
- connectionType: type,
3524
- propertyId: propertyId ?? null,
3525
- accessToken: tokens.access_token,
3526
- refreshToken: tokens.refresh_token ?? null,
3527
- tokenExpiresAt: expiresAt,
3528
- scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3529
- createdAt: now,
3530
- updatedAt: now
3531
- }).run();
3532
- }
3595
+ const existing = store.getConnection(domain, type);
3596
+ store.upsertConnection({
3597
+ domain,
3598
+ connectionType: type,
3599
+ propertyId: propertyId ?? existing?.propertyId ?? null,
3600
+ accessToken: tokens.access_token,
3601
+ refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
3602
+ tokenExpiresAt: expiresAt,
3603
+ scopes: tokens.scope?.split(" ") ?? [],
3604
+ createdAt: existing?.createdAt ?? now,
3605
+ updatedAt: now
3606
+ });
3533
3607
  writeAuditLog(app.db, {
3534
3608
  projectId: null,
3535
3609
  actor: "oauth",
@@ -3545,11 +3619,19 @@ async function googleRoutes(app, opts) {
3545
3619
  <p style="color:#888">You can close this tab.</p>
3546
3620
  </body></html>`
3547
3621
  );
3622
+ }
3623
+ app.get("/google/callback", async (request, reply) => {
3624
+ return handleOAuthCallback(request, reply);
3625
+ });
3626
+ app.get("/projects/:name/google/callback", async (request, reply) => {
3627
+ return handleOAuthCallback(request, reply);
3548
3628
  });
3549
3629
  app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
3630
+ const store = requireConnectionStore(reply);
3631
+ if (!store) return;
3550
3632
  const project = resolveProject(app.db, request.params.name);
3551
- const deleted = app.db.delete(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).run();
3552
- if (deleted.changes === 0) {
3633
+ const deleted = store.deleteConnection(project.canonicalDomain, request.params.type);
3634
+ if (!deleted) {
3553
3635
  const err = notFound("Google connection", request.params.type);
3554
3636
  return reply.status(err.statusCode).send(err.toJSON());
3555
3637
  }
@@ -3563,18 +3645,23 @@ async function googleRoutes(app, opts) {
3563
3645
  return reply.status(204).send();
3564
3646
  });
3565
3647
  app.get("/projects/:name/google/properties", async (request, reply) => {
3648
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3566
3649
  if (!googleClientId || !googleClientSecret) {
3567
3650
  const err = validationError("Google OAuth is not configured");
3568
3651
  return reply.status(err.statusCode).send(err.toJSON());
3569
3652
  }
3653
+ const store = requireConnectionStore(reply);
3654
+ if (!store) return;
3570
3655
  const project = resolveProject(app.db, request.params.name);
3571
- const { accessToken } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3656
+ const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3572
3657
  const sites = await listSites(accessToken);
3573
3658
  return { sites };
3574
3659
  });
3575
3660
  app.post("/projects/:name/google/gsc/sync", async (request, reply) => {
3661
+ const store = requireConnectionStore(reply);
3662
+ if (!store) return;
3576
3663
  const project = resolveProject(app.db, request.params.name);
3577
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, "gsc"))).get();
3664
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
3578
3665
  if (!conn) {
3579
3666
  const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
3580
3667
  return reply.status(err.statusCode).send(err.toJSON());
@@ -3604,7 +3691,7 @@ async function googleRoutes(app, opts) {
3604
3691
  if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
3605
3692
  if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
3606
3693
  if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
3607
- const rows = app.db.select().from(gscSearchData).where(and4(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
3694
+ const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
3608
3695
  return rows.map((r) => ({
3609
3696
  date: r.date,
3610
3697
  query: r.query,
@@ -3618,17 +3705,20 @@ async function googleRoutes(app, opts) {
3618
3705
  }));
3619
3706
  });
3620
3707
  app.post("/projects/:name/google/gsc/inspect", async (request, reply) => {
3708
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3621
3709
  if (!googleClientId || !googleClientSecret) {
3622
3710
  const err = validationError("Google OAuth is not configured");
3623
3711
  return reply.status(err.statusCode).send(err.toJSON());
3624
3712
  }
3713
+ const store = requireConnectionStore(reply);
3714
+ if (!store) return;
3625
3715
  const project = resolveProject(app.db, request.params.name);
3626
3716
  const { url } = request.body ?? {};
3627
3717
  if (!url) {
3628
3718
  const err = validationError("url is required");
3629
3719
  return reply.status(err.statusCode).send(err.toJSON());
3630
3720
  }
3631
- const { accessToken, propertyId } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3721
+ const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3632
3722
  if (!propertyId) {
3633
3723
  const err = validationError("No GSC property configured for this connection");
3634
3724
  return reply.status(err.statusCode).send(err.toJSON());
@@ -3679,7 +3769,7 @@ async function googleRoutes(app, opts) {
3679
3769
  const { url, limit } = request.query;
3680
3770
  const conditions = [eq12(gscUrlInspections.projectId, project.id)];
3681
3771
  if (url) conditions.push(eq12(gscUrlInspections.url, url));
3682
- const rows = app.db.select().from(gscUrlInspections).where(and4(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
3772
+ const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
3683
3773
  return rows.map((r) => ({
3684
3774
  id: r.id,
3685
3775
  url: r.url,
@@ -3725,18 +3815,23 @@ async function googleRoutes(app, opts) {
3725
3815
  return deindexed;
3726
3816
  });
3727
3817
  app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3818
+ const store = requireConnectionStore(reply);
3819
+ if (!store) return;
3728
3820
  const project = resolveProject(app.db, request.params.name);
3729
3821
  const { propertyId } = request.body ?? {};
3730
3822
  if (!propertyId) {
3731
3823
  const err = validationError("propertyId is required");
3732
3824
  return reply.status(err.statusCode).send(err.toJSON());
3733
3825
  }
3734
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).get();
3826
+ const conn = store.updateConnection(
3827
+ project.canonicalDomain,
3828
+ request.params.type,
3829
+ { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
3830
+ );
3735
3831
  if (!conn) {
3736
3832
  const err = notFound("Google connection", request.params.type);
3737
3833
  return reply.status(err.statusCode).send(err.toJSON());
3738
3834
  }
3739
- app.db.update(googleConnections).set({ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(googleConnections.id, conn.id)).run();
3740
3835
  return { propertyId };
3741
3836
  });
3742
3837
  }
@@ -3758,12 +3853,20 @@ async function apiRoutes(app, opts) {
3758
3853
  await api.register(competitorRoutes);
3759
3854
  await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
3760
3855
  await api.register(applyRoutes, {
3761
- onScheduleUpdated: opts.onScheduleUpdated
3856
+ onScheduleUpdated: opts.onScheduleUpdated,
3857
+ onGoogleConnectionPropertyUpdated: (domain, connectionType, propertyId) => {
3858
+ opts.googleConnectionStore?.updateConnection(domain, connectionType, {
3859
+ propertyId,
3860
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3861
+ });
3862
+ }
3762
3863
  });
3763
3864
  await api.register(historyRoutes);
3764
3865
  await api.register(settingsRoutes, {
3765
3866
  providerSummary: opts.providerSummary,
3766
- onProviderUpdate: opts.onProviderUpdate
3867
+ onProviderUpdate: opts.onProviderUpdate,
3868
+ google: opts.googleSettingsSummary,
3869
+ onGoogleUpdate: opts.onGoogleSettingsUpdate
3767
3870
  });
3768
3871
  await api.register(scheduleRoutes, {
3769
3872
  onScheduleUpdated: opts.onScheduleUpdated
@@ -3774,9 +3877,10 @@ async function apiRoutes(app, opts) {
3774
3877
  setTelemetryEnabled: opts.setTelemetryEnabled
3775
3878
  });
3776
3879
  await api.register(googleRoutes, {
3777
- googleClientId: opts.googleClientId,
3778
- googleClientSecret: opts.googleClientSecret,
3880
+ getGoogleAuthConfig: opts.getGoogleAuthConfig,
3881
+ googleConnectionStore: opts.googleConnectionStore,
3779
3882
  googleStateSecret: opts.googleStateSecret,
3883
+ publicUrl: opts.publicUrl,
3780
3884
  onGscSyncRequested: opts.onGscSyncRequested
3781
3885
  });
3782
3886
  }, { prefix: "/api/v1" });
@@ -4698,6 +4802,77 @@ var localAdapter = {
4698
4802
  }
4699
4803
  };
4700
4804
 
4805
+ // src/google-config.ts
4806
+ function ensureConnections(config) {
4807
+ if (!config.google) config.google = {};
4808
+ if (!config.google.connections) config.google.connections = [];
4809
+ return config.google.connections;
4810
+ }
4811
+ function getGoogleAuthConfig(config) {
4812
+ return {
4813
+ clientId: config.google?.clientId,
4814
+ clientSecret: config.google?.clientSecret
4815
+ };
4816
+ }
4817
+ function setGoogleAuthConfig(config, auth) {
4818
+ const hasValues = Boolean(auth.clientId || auth.clientSecret || config.google?.connections?.length);
4819
+ if (!hasValues) {
4820
+ delete config.google;
4821
+ return;
4822
+ }
4823
+ if (!config.google) config.google = {};
4824
+ config.google.clientId = auth.clientId;
4825
+ config.google.clientSecret = auth.clientSecret;
4826
+ config.google.connections = config.google.connections ?? [];
4827
+ }
4828
+ function listGoogleConnections(config, domain) {
4829
+ return (config.google?.connections ?? []).filter((connection) => connection.domain === domain);
4830
+ }
4831
+ function getGoogleConnection(config, domain, connectionType) {
4832
+ return (config.google?.connections ?? []).find((connection) => connection.domain === domain && connection.connectionType === connectionType);
4833
+ }
4834
+ function upsertGoogleConnection(config, connection) {
4835
+ const connections = ensureConnections(config);
4836
+ const index2 = connections.findIndex((entry) => entry.domain === connection.domain && entry.connectionType === connection.connectionType);
4837
+ const normalized = {
4838
+ ...connection,
4839
+ propertyId: connection.propertyId ?? null,
4840
+ refreshToken: connection.refreshToken ?? null,
4841
+ tokenExpiresAt: connection.tokenExpiresAt ?? null,
4842
+ scopes: connection.scopes ?? []
4843
+ };
4844
+ if (index2 === -1) {
4845
+ connections.push(normalized);
4846
+ return normalized;
4847
+ }
4848
+ connections[index2] = normalized;
4849
+ return normalized;
4850
+ }
4851
+ function patchGoogleConnection(config, domain, connectionType, patch) {
4852
+ const existing = getGoogleConnection(config, domain, connectionType);
4853
+ if (!existing) return void 0;
4854
+ return upsertGoogleConnection(config, {
4855
+ ...existing,
4856
+ ...patch,
4857
+ propertyId: Object.prototype.hasOwnProperty.call(patch, "propertyId") ? patch.propertyId ?? null : existing.propertyId ?? null,
4858
+ refreshToken: Object.prototype.hasOwnProperty.call(patch, "refreshToken") ? patch.refreshToken ?? null : existing.refreshToken ?? null,
4859
+ tokenExpiresAt: Object.prototype.hasOwnProperty.call(patch, "tokenExpiresAt") ? patch.tokenExpiresAt ?? null : existing.tokenExpiresAt ?? null,
4860
+ scopes: patch.scopes ?? existing.scopes ?? []
4861
+ });
4862
+ }
4863
+ function removeGoogleConnection(config, domain, connectionType) {
4864
+ const connections = config.google?.connections;
4865
+ if (!connections?.length) return false;
4866
+ const next = connections.filter((connection) => connection.domain !== domain || connection.connectionType !== connectionType);
4867
+ if (next.length === connections.length) return false;
4868
+ if (!config.google) return false;
4869
+ config.google.connections = next;
4870
+ if (!config.google.clientId && !config.google.clientSecret && next.length === 0) {
4871
+ delete config.google;
4872
+ }
4873
+ return true;
4874
+ }
4875
+
4701
4876
  // src/job-runner.ts
4702
4877
  import crypto13 from "crypto";
4703
4878
  import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
@@ -4961,7 +5136,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
4961
5136
 
4962
5137
  // src/gsc-sync.ts
4963
5138
  import crypto14 from "crypto";
4964
- import { eq as eq14, and as and5, sql as sql3 } from "drizzle-orm";
5139
+ import { eq as eq14, and as and4, sql as sql3 } from "drizzle-orm";
4965
5140
  function formatDate(d) {
4966
5141
  return d.toISOString().split("T")[0];
4967
5142
  }
@@ -4974,11 +5149,15 @@ async function executeGscSync(db, runId, projectId, opts) {
4974
5149
  const now = (/* @__PURE__ */ new Date()).toISOString();
4975
5150
  db.update(runs).set({ status: "running", startedAt: now }).where(eq14(runs.id, runId)).run();
4976
5151
  try {
5152
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5153
+ if (!googleClientId || !googleClientSecret) {
5154
+ throw new Error("Google OAuth is not configured in the local Canonry config");
5155
+ }
4977
5156
  const project = db.select().from(projects).where(eq14(projects.id, projectId)).get();
4978
5157
  if (!project) {
4979
5158
  throw new Error(`Project not found: ${projectId}`);
4980
5159
  }
4981
- const conn = db.select().from(googleConnections).where(and5(eq14(googleConnections.domain, project.canonicalDomain), eq14(googleConnections.connectionType, "gsc"))).get();
5160
+ const conn = getGoogleConnection(opts.config, project.canonicalDomain, "gsc");
4982
5161
  if (!conn || !conn.refreshToken) {
4983
5162
  throw new Error("No GSC connection found or connection is incomplete");
4984
5163
  }
@@ -4988,13 +5167,14 @@ async function executeGscSync(db, runId, projectId, opts) {
4988
5167
  let accessToken = conn.accessToken;
4989
5168
  const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
4990
5169
  if (Date.now() > expiresAt - 5 * 60 * 1e3) {
4991
- const tokens = await refreshAccessToken(opts.googleClientId, opts.googleClientSecret, conn.refreshToken);
5170
+ const tokens = await refreshAccessToken(googleClientId, googleClientSecret, conn.refreshToken);
4992
5171
  accessToken = tokens.access_token;
4993
- db.update(googleConnections).set({
5172
+ patchGoogleConnection(opts.config, project.canonicalDomain, "gsc", {
4994
5173
  accessToken: tokens.access_token,
4995
5174
  tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
4996
5175
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4997
- }).where(eq14(googleConnections.id, conn.id)).run();
5176
+ });
5177
+ saveConfig(opts.config);
4998
5178
  }
4999
5179
  const lagOffset = GSC_DATA_LAG_DAYS;
5000
5180
  const endDate = formatDate(daysAgo(lagOffset));
@@ -5007,7 +5187,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5007
5187
  });
5008
5188
  console.log(`[GSC Sync] Received ${rows.length} rows`);
5009
5189
  db.delete(gscSearchData).where(
5010
- and5(
5190
+ and4(
5011
5191
  eq14(gscSearchData.projectId, projectId),
5012
5192
  sql3`${gscSearchData.date} >= ${startDate}`,
5013
5193
  sql3`${gscSearchData.date} <= ${endDate}`
@@ -5155,8 +5335,7 @@ var Scheduler = class {
5155
5335
  /** Stop all cron tasks for graceful shutdown. */
5156
5336
  stop() {
5157
5337
  for (const [projectId, task] of this.tasks) {
5158
- task.stop();
5159
- console.log(`[Scheduler] Stopped task for project ${projectId}`);
5338
+ this.stopTask(projectId, task, "Stopped");
5160
5339
  }
5161
5340
  this.tasks.clear();
5162
5341
  }
@@ -5164,7 +5343,7 @@ var Scheduler = class {
5164
5343
  upsert(projectId) {
5165
5344
  const existing = this.tasks.get(projectId);
5166
5345
  if (existing) {
5167
- existing.stop();
5346
+ this.stopTask(projectId, existing, "Stopped");
5168
5347
  this.tasks.delete(projectId);
5169
5348
  }
5170
5349
  const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
@@ -5176,11 +5355,15 @@ var Scheduler = class {
5176
5355
  remove(projectId) {
5177
5356
  const existing = this.tasks.get(projectId);
5178
5357
  if (existing) {
5179
- existing.stop();
5358
+ this.stopTask(projectId, existing, "Removed");
5180
5359
  this.tasks.delete(projectId);
5181
- console.log(`[Scheduler] Removed task for project ${projectId}`);
5182
5360
  }
5183
5361
  }
5362
+ stopTask(projectId, task, verb) {
5363
+ task.stop();
5364
+ task.destroy();
5365
+ console.log(`[Scheduler] ${verb} task for project ${projectId}`);
5366
+ }
5184
5367
  registerCronTask(schedule) {
5185
5368
  const { id: scheduleId, projectId, cronExpr, timezone } = schedule;
5186
5369
  if (!cron.validate(cronExpr)) {
@@ -5244,7 +5427,7 @@ var Scheduler = class {
5244
5427
  };
5245
5428
 
5246
5429
  // src/notifier.ts
5247
- import { eq as eq16, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
5430
+ import { eq as eq16, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5248
5431
  import crypto15 from "crypto";
5249
5432
  var Notifier = class {
5250
5433
  db;
@@ -5307,7 +5490,7 @@ var Notifier = class {
5307
5490
  }
5308
5491
  computeTransitions(runId, projectId) {
5309
5492
  const recentRuns = this.db.select().from(runs).where(
5310
- and6(
5493
+ and5(
5311
5494
  eq16(runs.projectId, projectId),
5312
5495
  or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
5313
5496
  )
@@ -5515,18 +5698,19 @@ var DEFAULT_QUOTA = {
5515
5698
  maxRequestsPerDay: 1e3
5516
5699
  };
5517
5700
  async function createServer(opts) {
5518
- const app = Fastify({
5519
- logger: {
5520
- transport: {
5521
- target: "pino-pretty",
5522
- options: {
5523
- colorize: true,
5524
- translateTime: "HH:MM:ss",
5525
- ignore: "pid,hostname,reqId",
5526
- messageFormat: "{msg} {req.method} {req.url}"
5527
- }
5701
+ const logger = opts.logger === false ? false : process.stdout.isTTY ? {
5702
+ transport: {
5703
+ target: "pino-pretty",
5704
+ options: {
5705
+ colorize: true,
5706
+ translateTime: "HH:MM:ss",
5707
+ ignore: "pid,hostname,reqId",
5708
+ messageFormat: "{msg} {req.method} {req.url}"
5528
5709
  }
5529
5710
  }
5711
+ } : true;
5712
+ const app = Fastify({
5713
+ logger
5530
5714
  });
5531
5715
  const registry = new ProviderRegistry();
5532
5716
  const providers = opts.config.providers ?? {};
@@ -5593,25 +5777,46 @@ async function createServer(opts) {
5593
5777
  configured: !!registry.get(name),
5594
5778
  quota: registry.get(name)?.config.quotaPolicy
5595
5779
  }));
5780
+ const googleSettingsSummary = {
5781
+ configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
5782
+ };
5596
5783
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
5597
- const googleClientId = process.env.GOOGLE_CLIENT_ID;
5598
- const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
5599
5784
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
5785
+ const googleConnectionStore = {
5786
+ listConnections: (domain) => listGoogleConnections(opts.config, domain),
5787
+ getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
5788
+ upsertConnection: (connection) => {
5789
+ const updated = upsertGoogleConnection(opts.config, connection);
5790
+ saveConfig(opts.config);
5791
+ return updated;
5792
+ },
5793
+ updateConnection: (domain, connectionType, patch) => {
5794
+ const updated = patchGoogleConnection(opts.config, domain, connectionType, patch);
5795
+ if (updated) saveConfig(opts.config);
5796
+ return updated;
5797
+ },
5798
+ deleteConnection: (domain, connectionType) => {
5799
+ const removed = removeGoogleConnection(opts.config, domain, connectionType);
5800
+ if (removed) saveConfig(opts.config);
5801
+ return removed;
5802
+ }
5803
+ };
5600
5804
  await app.register(apiRoutes, {
5601
5805
  db: opts.db,
5602
5806
  skipAuth: false,
5603
- googleClientId,
5604
- googleClientSecret,
5807
+ getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
5808
+ googleConnectionStore,
5605
5809
  googleStateSecret,
5810
+ publicUrl: opts.config.publicUrl,
5606
5811
  onGscSyncRequested: (runId, projectId, syncOpts) => {
5812
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5607
5813
  if (!googleClientId || !googleClientSecret) {
5608
- app.log.error("GSC sync requested but GOOGLE_CLIENT_ID/SECRET not configured");
5814
+ app.log.error("GSC sync requested but Google OAuth credentials are not configured in the local config");
5609
5815
  return;
5610
5816
  }
5611
5817
  executeGscSync(opts.db, runId, projectId, {
5612
5818
  ...syncOpts,
5613
- googleClientId,
5614
- googleClientSecret
5819
+ config: opts.config
5615
5820
  }).catch((err) => {
5616
5821
  app.log.error({ runId, err }, "GSC sync failed");
5617
5822
  });
@@ -5621,6 +5826,7 @@ async function createServer(opts) {
5621
5826
  version: PKG_VERSION
5622
5827
  },
5623
5828
  providerSummary,
5829
+ googleSettingsSummary,
5624
5830
  onRunCreated: (runId, projectId, providers2) => {
5625
5831
  jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
5626
5832
  app.log.error({ runId, err }, "Job runner failed");
@@ -5665,6 +5871,17 @@ async function createServer(opts) {
5665
5871
  quota
5666
5872
  };
5667
5873
  },
5874
+ onGoogleSettingsUpdate: (clientId, clientSecret) => {
5875
+ try {
5876
+ setGoogleAuthConfig(opts.config, { clientId, clientSecret });
5877
+ saveConfig(opts.config);
5878
+ googleSettingsSummary.configured = true;
5879
+ return { ...googleSettingsSummary };
5880
+ } catch (err) {
5881
+ app.log.error({ err }, "Failed to save Google OAuth config");
5882
+ return null;
5883
+ }
5884
+ },
5668
5885
  onScheduleUpdated: (action, projectId) => {
5669
5886
  if (action === "upsert") scheduler.upsert(projectId);
5670
5887
  if (action === "delete") scheduler.remove(projectId);
@@ -5814,5 +6031,6 @@ export {
5814
6031
  isFirstRun,
5815
6032
  showFirstRunNotice,
5816
6033
  trackEvent,
6034
+ setGoogleAuthConfig,
5817
6035
  createServer
5818
6036
  };