@ainyc/canonry 1.25.3 → 1.26.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/index.html CHANGED
@@ -12,8 +12,8 @@
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-DRL4RV8N.js"></script>
16
- <link rel="stylesheet" crossorigin href="./assets/index-CoA39nr6.css">
15
+ <script type="module" crossorigin src="./assets/index-CE8TwvzO.js"></script>
16
+ <link rel="stylesheet" crossorigin href="./assets/index-D786SQZN.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -72,6 +72,10 @@ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "ca
72
72
  } catch {
73
73
  }
74
74
  }
75
+ if ("CANONRY_BASE_PATH" in process.env) {
76
+ const val = process.env.CANONRY_BASE_PATH.trim();
77
+ parsed.basePath = val || void 0;
78
+ }
75
79
  if (parsed.basePath) {
76
80
  const normalizedBase = "/" + parsed.basePath.replace(/^\/|\/$/g, "");
77
81
  try {
@@ -174,6 +178,7 @@ import crypto21 from "crypto";
174
178
  import fs5 from "fs";
175
179
  import path6 from "path";
176
180
  import { fileURLToPath } from "url";
181
+ import { eq as eq22 } from "drizzle-orm";
177
182
  import Fastify from "fastify";
178
183
 
179
184
  // ../contracts/src/config-schema.ts
@@ -220,6 +225,8 @@ var notificationDtoSchema = z2.object({
220
225
  projectId: z2.string(),
221
226
  channel: z2.literal("webhook"),
222
227
  url: z2.string().url(),
228
+ urlDisplay: z2.string(),
229
+ urlHost: z2.string(),
223
230
  events: z2.array(notificationEventSchema),
224
231
  enabled: z2.boolean().default(true),
225
232
  webhookSecret: z2.string().optional(),
@@ -1390,29 +1397,60 @@ function shouldSkipAuth(url) {
1390
1397
  if (SKIP_PATHS.includes(url)) return true;
1391
1398
  if (url.endsWith("/openapi.json")) return true;
1392
1399
  if (url.includes("/google/callback")) return true;
1400
+ if (url.endsWith("/session") || url.endsWith("/session/setup")) return true;
1393
1401
  return false;
1394
1402
  }
1395
- async function authPlugin(app) {
1403
+ function parseCookies(header) {
1404
+ if (!header) return {};
1405
+ return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
1406
+ const eqIdx = part.indexOf("=");
1407
+ if (eqIdx <= 0) return cookies;
1408
+ const name = part.slice(0, eqIdx).trim();
1409
+ const value = part.slice(eqIdx + 1).trim();
1410
+ if (!name) return cookies;
1411
+ try {
1412
+ cookies[name] = decodeURIComponent(value);
1413
+ } catch {
1414
+ cookies[name] = value;
1415
+ }
1416
+ return cookies;
1417
+ }, {});
1418
+ }
1419
+ async function authPlugin(app, opts = {}) {
1396
1420
  app.addHook("onRequest", async (request, reply) => {
1397
1421
  const url = request.url.split("?")[0];
1398
1422
  if (shouldSkipAuth(url)) return;
1399
1423
  const header = request.headers.authorization;
1400
- if (!header) {
1401
- const err = authRequired();
1402
- return reply.status(err.statusCode).send(err.toJSON());
1403
- }
1404
- const parts = header.split(" ");
1405
- if (parts.length !== 2 || parts[0] !== "Bearer") {
1424
+ let key;
1425
+ if (header) {
1426
+ const parts = header.split(" ");
1427
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
1428
+ const err = authRequired();
1429
+ return reply.status(err.statusCode).send(err.toJSON());
1430
+ }
1431
+ const token = parts[1];
1432
+ const hash = hashKey(token);
1433
+ key = app.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).get();
1434
+ if (!key || key.revokedAt) {
1435
+ const err = authInvalid();
1436
+ return reply.status(err.statusCode).send(err.toJSON());
1437
+ }
1438
+ } else if (opts.resolveSessionApiKeyId && opts.sessionCookieName) {
1439
+ const sessionId = parseCookies(request.headers.cookie)[opts.sessionCookieName];
1440
+ if (sessionId) {
1441
+ const apiKeyId = await opts.resolveSessionApiKeyId(sessionId);
1442
+ if (apiKeyId) {
1443
+ key = app.db.select().from(apiKeys).where(eq(apiKeys.id, apiKeyId)).get();
1444
+ }
1445
+ }
1446
+ if (!key || key.revokedAt) {
1447
+ const err = authRequired();
1448
+ return reply.status(err.statusCode).send(err.toJSON());
1449
+ }
1450
+ } else {
1406
1451
  const err = authRequired();
1407
1452
  return reply.status(err.statusCode).send(err.toJSON());
1408
1453
  }
1409
- const token = parts[1];
1410
- const hash = hashKey(token);
1411
- const key = app.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).get();
1412
- if (!key || key.revokedAt) {
1413
- const err = authInvalid();
1414
- return reply.status(err.statusCode).send(err.toJSON());
1415
- }
1416
1454
  app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(apiKeys.id, key.id)).run();
1417
1455
  });
1418
1456
  }
@@ -2801,6 +2839,48 @@ async function applyRoutes(app, opts) {
2801
2839
 
2802
2840
  // ../api-routes/src/history.ts
2803
2841
  import { eq as eq9, desc as desc2, inArray } from "drizzle-orm";
2842
+
2843
+ // ../api-routes/src/notification-redaction.ts
2844
+ var REDACTED_URL = {
2845
+ url: "https://redacted.invalid/redacted",
2846
+ urlDisplay: "invalid-url/redacted",
2847
+ urlHost: "invalid-url"
2848
+ };
2849
+ function redactNotificationUrl(rawUrl) {
2850
+ try {
2851
+ const parsed = new URL(rawUrl);
2852
+ const host = parsed.host || parsed.hostname;
2853
+ return {
2854
+ url: `${parsed.protocol}//${host}/redacted`,
2855
+ urlDisplay: `${host}/redacted`,
2856
+ urlHost: host
2857
+ };
2858
+ } catch {
2859
+ return REDACTED_URL;
2860
+ }
2861
+ }
2862
+ function redactNotificationDiff(value) {
2863
+ if (Array.isArray(value)) {
2864
+ return value.map(redactNotificationDiff);
2865
+ }
2866
+ if (!value || typeof value !== "object") {
2867
+ return value;
2868
+ }
2869
+ const output = {};
2870
+ for (const [key, entry] of Object.entries(value)) {
2871
+ if (key === "url" && typeof entry === "string") {
2872
+ const redacted = redactNotificationUrl(entry);
2873
+ output.url = redacted.url;
2874
+ output.urlDisplay = redacted.urlDisplay;
2875
+ output.urlHost = redacted.urlHost;
2876
+ continue;
2877
+ }
2878
+ output[key] = redactNotificationDiff(entry);
2879
+ }
2880
+ return output;
2881
+ }
2882
+
2883
+ // ../api-routes/src/history.ts
2804
2884
  async function historyRoutes(app) {
2805
2885
  app.get("/projects/:name/history", async (request, reply) => {
2806
2886
  const project = resolveProjectSafe4(app, request.params.name, reply);
@@ -2990,7 +3070,7 @@ function formatAuditEntry(row) {
2990
3070
  action: row.action,
2991
3071
  entityType: row.entityType,
2992
3072
  entityId: row.entityId,
2993
- diff: row.diff ? tryParseJson2(row.diff, null) : null,
3073
+ diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(tryParseJson2(row.diff, null)) : tryParseJson2(row.diff, null) : null,
2994
3074
  createdAt: row.createdAt
2995
3075
  };
2996
3076
  }
@@ -5285,7 +5365,7 @@ async function notificationRoutes(app) {
5285
5365
  action: "notification.created",
5286
5366
  entityType: "notification",
5287
5367
  entityId: id,
5288
- diff: { channel, url, events }
5368
+ diff: { channel, ...redactNotificationUrl(url), events }
5289
5369
  });
5290
5370
  return reply.status(201).send({
5291
5371
  ...formatNotification(app.db.select().from(notifications).where(eq12(notifications.id, id)).get()),
@@ -5343,9 +5423,10 @@ async function notificationRoutes(app) {
5343
5423
  ],
5344
5424
  dashboardUrl: `/projects/${project.name}`
5345
5425
  };
5346
- request.log.info(`[Notification test] POST ${config.url}`);
5426
+ const targetLabel = redactNotificationUrl(config.url).urlDisplay;
5427
+ request.log.info(`[Notification test] POST ${targetLabel}`);
5347
5428
  const { status, error } = await deliverWebhook(urlCheck.target, payload, notification.webhookSecret ?? null);
5348
- request.log.info(`[Notification test] Response: HTTP ${status} from ${config.url}`);
5429
+ request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
5349
5430
  writeAuditLog(app.db, {
5350
5431
  projectId: project.id,
5351
5432
  actor: "api",
@@ -5362,11 +5443,14 @@ async function notificationRoutes(app) {
5362
5443
  }
5363
5444
  function formatNotification(row) {
5364
5445
  const config = JSON.parse(row.config);
5446
+ const redacted = redactNotificationUrl(config.url);
5365
5447
  return {
5366
5448
  id: row.id,
5367
5449
  projectId: row.projectId,
5368
5450
  channel: "webhook",
5369
- url: config.url,
5451
+ url: redacted.url,
5452
+ urlDisplay: redacted.urlDisplay,
5453
+ urlHost: redacted.urlHost,
5370
5454
  events: config.events,
5371
5455
  enabled: row.enabled === 1,
5372
5456
  createdAt: row.createdAt,
@@ -7472,10 +7556,13 @@ async function apiRoutes(app, opts) {
7472
7556
  }
7473
7557
  });
7474
7558
  });
7475
- if (!opts.skipAuth) {
7476
- await app.register(authPlugin);
7477
- }
7478
7559
  await app.register(async (api) => {
7560
+ if (!opts.skipAuth) {
7561
+ await authPlugin(api, {
7562
+ sessionCookieName: opts.sessionCookieName,
7563
+ resolveSessionApiKeyId: opts.resolveSessionApiKeyId
7564
+ });
7565
+ }
7479
7566
  await api.register(openApiRoutes, opts.openApiInfo ?? {});
7480
7567
  await api.register(projectRoutes, {
7481
7568
  onProjectDeleted: opts.onProjectDeleted,
@@ -10534,25 +10621,26 @@ var Notifier = class {
10534
10621
  return transitions;
10535
10622
  }
10536
10623
  async sendWebhook(url, payload, notificationId, projectId, webhookSecret) {
10624
+ const targetLabel = redactNotificationUrl(url).urlDisplay;
10537
10625
  const targetCheck = await resolveWebhookTarget(url);
10538
10626
  if (!targetCheck.ok) {
10539
- log5.error("webhook.ssrf-blocked", { url, reason: targetCheck.message });
10627
+ log5.error("webhook.ssrf-blocked", { url: targetLabel, reason: targetCheck.message });
10540
10628
  this.logDelivery(projectId, notificationId, payload.event, "failed", `SSRF: ${targetCheck.message}`);
10541
10629
  return;
10542
10630
  }
10543
- log5.info("webhook.send", { event: payload.event, url });
10631
+ log5.info("webhook.send", { event: payload.event, url: targetLabel });
10544
10632
  const maxRetries = 3;
10545
10633
  const delays = [1e3, 4e3, 16e3];
10546
10634
  for (let attempt = 0; attempt < maxRetries; attempt++) {
10547
10635
  try {
10548
10636
  const response = await deliverWebhook(targetCheck.target, payload, webhookSecret);
10549
10637
  if (response.status >= 200 && response.status < 300) {
10550
- log5.info("webhook.delivered", { event: payload.event, url, httpStatus: response.status });
10638
+ log5.info("webhook.delivered", { event: payload.event, url: targetLabel, httpStatus: response.status });
10551
10639
  this.logDelivery(projectId, notificationId, payload.event, "sent", null);
10552
10640
  return;
10553
10641
  }
10554
10642
  const errorDetail = response.error ?? `HTTP ${response.status}`;
10555
- log5.warn("webhook.attempt-failed", { event: payload.event, url, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
10643
+ log5.warn("webhook.attempt-failed", { event: payload.event, url: targetLabel, attempt: attempt + 1, maxRetries, httpStatus: response.status, error: errorDetail });
10556
10644
  if (attempt === maxRetries - 1) {
10557
10645
  this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
10558
10646
  }
@@ -10560,7 +10648,7 @@ var Notifier = class {
10560
10648
  const errorDetail = err instanceof Error ? err.message : String(err);
10561
10649
  if (attempt === maxRetries - 1) {
10562
10650
  this.logDelivery(projectId, notificationId, payload.event, "failed", errorDetail);
10563
- log5.error("webhook.exhausted", { event: payload.event, url, maxRetries, error: errorDetail });
10651
+ log5.error("webhook.exhausted", { event: payload.event, url: targetLabel, maxRetries, error: errorDetail });
10564
10652
  }
10565
10653
  }
10566
10654
  if (attempt < maxRetries - 1) {
@@ -10702,6 +10790,8 @@ var DEFAULT_QUOTA = {
10702
10790
  maxRequestsPerMinute: 10,
10703
10791
  maxRequestsPerDay: 1e3
10704
10792
  };
10793
+ var SESSION_COOKIE_NAME = "canonry_session";
10794
+ var SESSION_TTL_MS = 12 * 60 * 60 * 1e3;
10705
10795
  var API_ADAPTERS = [
10706
10796
  geminiAdapter,
10707
10797
  openaiAdapter,
@@ -10724,6 +10814,42 @@ function summarizeProviderConfig(provider, config) {
10724
10814
  quota: { ...config?.quota ?? DEFAULT_QUOTA }
10725
10815
  };
10726
10816
  }
10817
+ function hashApiKey(key) {
10818
+ return crypto21.createHash("sha256").update(key).digest("hex");
10819
+ }
10820
+ function parseCookies2(header) {
10821
+ if (!header) return {};
10822
+ return header.split(";").map((part) => part.trim()).filter(Boolean).reduce((cookies, part) => {
10823
+ const eqIdx = part.indexOf("=");
10824
+ if (eqIdx <= 0) return cookies;
10825
+ const name = part.slice(0, eqIdx).trim();
10826
+ const value = part.slice(eqIdx + 1).trim();
10827
+ if (!name) return cookies;
10828
+ try {
10829
+ cookies[name] = decodeURIComponent(value);
10830
+ } catch {
10831
+ cookies[name] = value;
10832
+ }
10833
+ return cookies;
10834
+ }, {});
10835
+ }
10836
+ function serializeSessionCookie(opts) {
10837
+ const parts = [
10838
+ `${opts.name}=${opts.value ? encodeURIComponent(opts.value) : ""}`,
10839
+ `Path=${opts.path}`,
10840
+ "HttpOnly",
10841
+ "SameSite=Lax"
10842
+ ];
10843
+ if (opts.value) {
10844
+ parts.push(`Max-Age=${Math.floor(opts.ttlMs / 1e3)}`);
10845
+ } else {
10846
+ parts.push("Max-Age=0");
10847
+ }
10848
+ if (opts.secure) {
10849
+ parts.push("Secure");
10850
+ }
10851
+ return parts.join("; ");
10852
+ }
10727
10853
  async function createServer(opts) {
10728
10854
  const logger = opts.logger === false ? false : process.stdout.isTTY ? {
10729
10855
  transport: {
@@ -10880,10 +11006,154 @@ async function createServer(opts) {
10880
11006
  const normalizedBasePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
10881
11007
  const basePath = normalizedBasePath === "/" ? void 0 : normalizedBasePath;
10882
11008
  const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
11009
+ if (opts.config.apiKey) {
11010
+ const keyHash = hashApiKey(opts.config.apiKey);
11011
+ const existing = opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, keyHash)).get();
11012
+ if (!existing) {
11013
+ const prefix = opts.config.apiKey.slice(0, 12);
11014
+ opts.db.insert(apiKeys).values({
11015
+ id: `key_${crypto21.randomBytes(8).toString("hex")}`,
11016
+ name: "default",
11017
+ keyHash,
11018
+ keyPrefix: prefix,
11019
+ scopes: JSON.stringify(["*"]),
11020
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
11021
+ }).run();
11022
+ }
11023
+ }
11024
+ const sessionCookiePath = basePath ?? "/";
11025
+ const sessionCookieSecure = Boolean(
11026
+ opts.config.publicUrl?.startsWith("https://") || opts.config.apiUrl?.startsWith("https://")
11027
+ );
11028
+ const sessions = /* @__PURE__ */ new Map();
11029
+ const pruneExpiredSessions = () => {
11030
+ const now = Date.now();
11031
+ for (const [sessionId, session] of sessions.entries()) {
11032
+ if (session.expiresAt <= now) {
11033
+ sessions.delete(sessionId);
11034
+ }
11035
+ }
11036
+ };
11037
+ const createSession = (apiKeyId) => {
11038
+ pruneExpiredSessions();
11039
+ const sessionId = crypto21.randomBytes(32).toString("hex");
11040
+ sessions.set(sessionId, {
11041
+ apiKeyId,
11042
+ expiresAt: Date.now() + SESSION_TTL_MS
11043
+ });
11044
+ return sessionId;
11045
+ };
11046
+ const resolveSessionApiKeyId = (sessionId) => {
11047
+ pruneExpiredSessions();
11048
+ const session = sessions.get(sessionId);
11049
+ if (!session) return null;
11050
+ if (session.expiresAt <= Date.now()) {
11051
+ sessions.delete(sessionId);
11052
+ return null;
11053
+ }
11054
+ return session.apiKeyId;
11055
+ };
11056
+ const clearSession = (sessionId) => {
11057
+ if (sessionId) {
11058
+ sessions.delete(sessionId);
11059
+ }
11060
+ };
11061
+ const getDefaultApiKey = () => {
11062
+ if (!opts.config.apiKey) return void 0;
11063
+ return opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, hashApiKey(opts.config.apiKey))).get();
11064
+ };
11065
+ const createPasswordSession = (reply) => {
11066
+ const key = getDefaultApiKey();
11067
+ if (!key || key.revokedAt) return false;
11068
+ const sessionId = createSession(key.id);
11069
+ reply.header("set-cookie", serializeSessionCookie({
11070
+ name: SESSION_COOKIE_NAME,
11071
+ value: sessionId,
11072
+ path: sessionCookiePath,
11073
+ secure: sessionCookieSecure,
11074
+ ttlMs: SESSION_TTL_MS
11075
+ }));
11076
+ return true;
11077
+ };
11078
+ app.get(apiPrefix + "/session", async (request, reply) => {
11079
+ const sessionId = parseCookies2(request.headers.cookie)[SESSION_COOKIE_NAME];
11080
+ return reply.send({
11081
+ authenticated: Boolean(sessionId && resolveSessionApiKeyId(sessionId)),
11082
+ setupRequired: !opts.config.dashboardPasswordHash
11083
+ });
11084
+ });
11085
+ app.post(apiPrefix + "/session/setup", async (request, reply) => {
11086
+ if (opts.config.dashboardPasswordHash) {
11087
+ const err = validationError("Dashboard password is already configured");
11088
+ return reply.status(err.statusCode).send(err.toJSON());
11089
+ }
11090
+ const password = request.body?.password?.trim();
11091
+ if (!password || password.length < 8) {
11092
+ const err = validationError("Password must be at least 8 characters");
11093
+ return reply.status(err.statusCode).send(err.toJSON());
11094
+ }
11095
+ opts.config.dashboardPasswordHash = hashApiKey(password);
11096
+ saveConfig(opts.config);
11097
+ if (!createPasswordSession(reply)) {
11098
+ const err = authInvalid();
11099
+ return reply.status(err.statusCode).send(err.toJSON());
11100
+ }
11101
+ return reply.send({ authenticated: true });
11102
+ });
11103
+ app.post(apiPrefix + "/session", async (request, reply) => {
11104
+ const password = request.body?.password?.trim();
11105
+ const apiKey = request.body?.apiKey?.trim();
11106
+ if (password) {
11107
+ if (!opts.config.dashboardPasswordHash) {
11108
+ const err2 = validationError("No dashboard password configured \u2014 use /session/setup first");
11109
+ return reply.status(err2.statusCode).send(err2.toJSON());
11110
+ }
11111
+ if (hashApiKey(password) !== opts.config.dashboardPasswordHash) {
11112
+ return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Incorrect password" } });
11113
+ }
11114
+ if (!createPasswordSession(reply)) {
11115
+ return reply.status(401).send({ error: { code: "AUTH_INVALID", message: "Server API key not found \u2014 re-run canonry init" } });
11116
+ }
11117
+ return reply.send({ authenticated: true });
11118
+ }
11119
+ if (apiKey) {
11120
+ const key = opts.db.select().from(apiKeys).where(eq22(apiKeys.keyHash, hashApiKey(apiKey))).get();
11121
+ if (!key || key.revokedAt) {
11122
+ const err2 = authInvalid();
11123
+ return reply.status(err2.statusCode).send(err2.toJSON());
11124
+ }
11125
+ opts.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq22(apiKeys.id, key.id)).run();
11126
+ const sessionId = createSession(key.id);
11127
+ reply.header("set-cookie", serializeSessionCookie({
11128
+ name: SESSION_COOKIE_NAME,
11129
+ value: sessionId,
11130
+ path: sessionCookiePath,
11131
+ secure: sessionCookieSecure,
11132
+ ttlMs: SESSION_TTL_MS
11133
+ }));
11134
+ return reply.send({ authenticated: true });
11135
+ }
11136
+ const err = validationError("Either password or apiKey is required");
11137
+ return reply.status(err.statusCode).send(err.toJSON());
11138
+ });
11139
+ app.delete(apiPrefix + "/session", async (request, reply) => {
11140
+ const sessionId = parseCookies2(request.headers.cookie)[SESSION_COOKIE_NAME];
11141
+ clearSession(sessionId);
11142
+ reply.header("set-cookie", serializeSessionCookie({
11143
+ name: SESSION_COOKIE_NAME,
11144
+ value: null,
11145
+ path: sessionCookiePath,
11146
+ secure: sessionCookieSecure,
11147
+ ttlMs: SESSION_TTL_MS
11148
+ }));
11149
+ return reply.status(204).send();
11150
+ });
10883
11151
  await app.register(apiRoutes, {
10884
11152
  db: opts.db,
10885
11153
  routePrefix: apiPrefix,
10886
11154
  skipAuth: false,
11155
+ sessionCookieName: SESSION_COOKIE_NAME,
11156
+ resolveSessionApiKeyId,
10887
11157
  getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
10888
11158
  googleConnectionStore,
10889
11159
  googleStateSecret,
@@ -11118,7 +11388,7 @@ async function createServer(opts) {
11118
11388
  if (fs5.existsSync(assetsDir)) {
11119
11389
  const indexPath = path6.join(assetsDir, "index.html");
11120
11390
  const injectConfig = (html) => {
11121
- const clientConfig = { apiKey: opts.config.apiKey };
11391
+ const clientConfig = {};
11122
11392
  if (basePath) clientConfig.basePath = basePath;
11123
11393
  const configScript = `<script>window.__CANONRY_CONFIG__=${JSON.stringify(clientConfig)}</script>`;
11124
11394
  const baseTag = basePath ? `<base href="${basePath}">` : "";
@@ -11162,7 +11432,12 @@ async function createServer(opts) {
11162
11432
  return reply.status(404).send({ error: "Not found" });
11163
11433
  });
11164
11434
  }
11165
- const healthHandler = async () => ({ status: "ok", service: "canonry", version: PKG_VERSION });
11435
+ const healthHandler = async () => ({
11436
+ status: "ok",
11437
+ service: "canonry",
11438
+ version: PKG_VERSION,
11439
+ ...basePath ? { basePath: basePath.replace(/\/$/, "") } : {}
11440
+ });
11166
11441
  app.get("/health", healthHandler);
11167
11442
  if (basePath) {
11168
11443
  app.get(`${basePath}health`, healthHandler);