@ainyc/canonry 1.7.1 → 1.8.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.
@@ -175,6 +175,7 @@ var projects = sqliteTable("projects", {
175
175
  name: text("name").notNull().unique(),
176
176
  displayName: text("display_name").notNull(),
177
177
  canonicalDomain: text("canonical_domain").notNull(),
178
+ ownedDomains: text("owned_domains").notNull().default("[]"),
178
179
  country: text("country").notNull(),
179
180
  language: text("language").notNull(),
180
181
  tags: text("tags").notNull().default("[]"),
@@ -313,6 +314,7 @@ CREATE TABLE IF NOT EXISTS projects (
313
314
  name TEXT NOT NULL UNIQUE,
314
315
  display_name TEXT NOT NULL,
315
316
  canonical_domain TEXT NOT NULL,
317
+ owned_domains TEXT NOT NULL DEFAULT '[]',
316
318
  country TEXT NOT NULL,
317
319
  language TEXT NOT NULL,
318
320
  tags TEXT NOT NULL DEFAULT '[]',
@@ -439,7 +441,9 @@ var MIGRATIONS = [
439
441
  // v2: Add providers column to projects for multi-provider support
440
442
  `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
441
443
  // v3: Add webhook_secret column to notifications for HMAC signing
442
- `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`
444
+ `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
445
+ // v4: Add owned_domains column to projects for multi-domain citation matching
446
+ `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`
443
447
  ];
444
448
  function migrate(db) {
445
449
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -515,6 +519,7 @@ var configNotificationSchema = z3.object({
515
519
  var configSpecSchema = z3.object({
516
520
  displayName: z3.string().min(1),
517
521
  canonicalDomain: z3.string().min(1),
522
+ ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
518
523
  country: z3.string().length(2),
519
524
  language: z3.string().min(2),
520
525
  keywords: z3.array(z3.string().min(1)).optional().default([]),
@@ -579,6 +584,7 @@ var projectDtoSchema = z4.object({
579
584
  name: z4.string(),
580
585
  displayName: z4.string().optional(),
581
586
  canonicalDomain: z4.string(),
587
+ ownedDomains: z4.array(z4.string()).default([]),
582
588
  country: z4.string().length(2),
583
589
  language: z4.string().min(2),
584
590
  tags: z4.array(z4.string()).default([]),
@@ -588,6 +594,30 @@ var projectDtoSchema = z4.object({
588
594
  createdAt: z4.string().optional(),
589
595
  updatedAt: z4.string().optional()
590
596
  });
597
+ function normalizeProjectDomain(input) {
598
+ let domain = input.trim().toLowerCase();
599
+ try {
600
+ if (domain.includes("://")) {
601
+ domain = new URL(domain).hostname.toLowerCase();
602
+ }
603
+ } catch {
604
+ }
605
+ return domain.replace(/^www\./, "");
606
+ }
607
+ function effectiveDomains(project) {
608
+ const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
609
+ const seen = /* @__PURE__ */ new Set();
610
+ const result = [];
611
+ for (const d of all) {
612
+ const trimmed = d.trim();
613
+ if (!trimmed) continue;
614
+ const norm = normalizeProjectDomain(trimmed);
615
+ if (seen.has(norm)) continue;
616
+ seen.add(norm);
617
+ result.push(trimmed);
618
+ }
619
+ return result;
620
+ }
591
621
 
592
622
  // ../contracts/src/run.ts
593
623
  import { z as z5 } from "zod";
@@ -726,12 +756,17 @@ async function projectRoutes(app, opts) {
726
756
  const err = validationError("Missing required fields: displayName, canonicalDomain, country, language");
727
757
  return reply.status(err.statusCode).send(err.toJSON());
728
758
  }
759
+ if (body.ownedDomains !== void 0 && (!Array.isArray(body.ownedDomains) || body.ownedDomains.some((d) => typeof d !== "string" || d.trim() === ""))) {
760
+ const err = validationError("ownedDomains must be an array of non-empty strings");
761
+ return reply.status(err.statusCode).send(err.toJSON());
762
+ }
729
763
  const now = (/* @__PURE__ */ new Date()).toISOString();
730
764
  const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
731
765
  if (existing) {
732
766
  app.db.update(projects).set({
733
767
  displayName: body.displayName,
734
768
  canonicalDomain: body.canonicalDomain,
769
+ ownedDomains: JSON.stringify(body.ownedDomains ?? []),
735
770
  country: body.country,
736
771
  language: body.language,
737
772
  tags: JSON.stringify(body.tags ?? []),
@@ -757,6 +792,7 @@ async function projectRoutes(app, opts) {
757
792
  name,
758
793
  displayName: body.displayName,
759
794
  canonicalDomain: body.canonicalDomain,
795
+ ownedDomains: JSON.stringify(body.ownedDomains ?? []),
760
796
  country: body.country,
761
797
  language: body.language,
762
798
  tags: JSON.stringify(body.tags ?? []),
@@ -840,6 +876,7 @@ async function projectRoutes(app, opts) {
840
876
  spec: {
841
877
  displayName: project.displayName,
842
878
  canonicalDomain: project.canonicalDomain,
879
+ ownedDomains: JSON.parse(project.ownedDomains || "[]"),
843
880
  country: project.country,
844
881
  language: project.language,
845
882
  keywords: kws.map((k) => k.keyword),
@@ -871,6 +908,7 @@ function formatProject(row) {
871
908
  name: row.name,
872
909
  displayName: row.displayName,
873
910
  canonicalDomain: row.canonicalDomain,
911
+ ownedDomains: JSON.parse(row.ownedDomains || "[]"),
874
912
  country: row.country,
875
913
  language: row.language,
876
914
  tags: JSON.parse(row.tags),
@@ -1552,6 +1590,7 @@ async function applyRoutes(app, opts) {
1552
1590
  app.db.update(projects).set({
1553
1591
  displayName: config.spec.displayName,
1554
1592
  canonicalDomain: config.spec.canonicalDomain,
1593
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
1555
1594
  country: config.spec.country,
1556
1595
  language: config.spec.language,
1557
1596
  labels: JSON.stringify(config.metadata.labels),
@@ -1574,6 +1613,7 @@ async function applyRoutes(app, opts) {
1574
1613
  name,
1575
1614
  displayName: config.spec.displayName,
1576
1615
  canonicalDomain: config.spec.canonicalDomain,
1616
+ ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
1577
1617
  country: config.spec.country,
1578
1618
  language: config.spec.language,
1579
1619
  tags: "[]",
@@ -1724,6 +1764,7 @@ async function applyRoutes(app, opts) {
1724
1764
  name: project.name,
1725
1765
  displayName: project.displayName,
1726
1766
  canonicalDomain: project.canonicalDomain,
1767
+ ownedDomains: JSON.parse(project.ownedDomains || "[]"),
1727
1768
  country: project.country,
1728
1769
  language: project.language,
1729
1770
  tags: JSON.parse(project.tags),
@@ -1929,6 +1970,669 @@ function resolveProjectSafe4(app, name, reply) {
1929
1970
  }
1930
1971
  }
1931
1972
 
1973
+ // ../api-routes/src/openapi.ts
1974
+ var stringSchema = { type: "string" };
1975
+ var booleanSchema = { type: "boolean" };
1976
+ var integerSchema = { type: "integer" };
1977
+ var objectSchema = { type: "object", additionalProperties: true };
1978
+ var stringArraySchema = { type: "array", items: stringSchema };
1979
+ var nameParameter = {
1980
+ name: "name",
1981
+ in: "path",
1982
+ required: true,
1983
+ description: "Project name.",
1984
+ schema: stringSchema
1985
+ };
1986
+ var runIdParameter = {
1987
+ name: "id",
1988
+ in: "path",
1989
+ required: true,
1990
+ description: "Run ID.",
1991
+ schema: stringSchema
1992
+ };
1993
+ var notificationIdParameter = {
1994
+ name: "id",
1995
+ in: "path",
1996
+ required: true,
1997
+ description: "Notification ID.",
1998
+ schema: stringSchema
1999
+ };
2000
+ var providerNameParameter = {
2001
+ name: "name",
2002
+ in: "path",
2003
+ required: true,
2004
+ description: "Provider name.",
2005
+ schema: { type: "string", enum: ["gemini", "openai", "claude", "local"] }
2006
+ };
2007
+ var routeCatalog = [
2008
+ {
2009
+ method: "get",
2010
+ path: "/api/v1/openapi.json",
2011
+ summary: "Get the OpenAPI document",
2012
+ description: "Machine-readable description of the Canonry API surface.",
2013
+ tags: ["meta"],
2014
+ auth: false,
2015
+ responses: {
2016
+ 200: { description: "OpenAPI document." }
2017
+ }
2018
+ },
2019
+ {
2020
+ method: "put",
2021
+ path: "/api/v1/projects/{name}",
2022
+ summary: "Create or update a project",
2023
+ tags: ["projects"],
2024
+ parameters: [nameParameter],
2025
+ requestBody: {
2026
+ required: true,
2027
+ content: {
2028
+ "application/json": {
2029
+ schema: {
2030
+ type: "object",
2031
+ required: ["displayName", "canonicalDomain", "country", "language"],
2032
+ properties: {
2033
+ displayName: stringSchema,
2034
+ canonicalDomain: stringSchema,
2035
+ country: stringSchema,
2036
+ language: stringSchema,
2037
+ tags: stringArraySchema,
2038
+ labels: objectSchema,
2039
+ providers: stringArraySchema,
2040
+ configSource: stringSchema
2041
+ }
2042
+ }
2043
+ }
2044
+ }
2045
+ },
2046
+ responses: {
2047
+ 200: { description: "Project updated." },
2048
+ 201: { description: "Project created." }
2049
+ }
2050
+ },
2051
+ {
2052
+ method: "get",
2053
+ path: "/api/v1/projects",
2054
+ summary: "List projects",
2055
+ tags: ["projects"],
2056
+ responses: {
2057
+ 200: { description: "Projects returned." }
2058
+ }
2059
+ },
2060
+ {
2061
+ method: "get",
2062
+ path: "/api/v1/projects/{name}",
2063
+ summary: "Get a project",
2064
+ tags: ["projects"],
2065
+ parameters: [nameParameter],
2066
+ responses: {
2067
+ 200: { description: "Project returned." },
2068
+ 404: { description: "Project not found." }
2069
+ }
2070
+ },
2071
+ {
2072
+ method: "delete",
2073
+ path: "/api/v1/projects/{name}",
2074
+ summary: "Delete a project",
2075
+ tags: ["projects"],
2076
+ parameters: [nameParameter],
2077
+ responses: {
2078
+ 204: { description: "Project deleted." },
2079
+ 404: { description: "Project not found." }
2080
+ }
2081
+ },
2082
+ {
2083
+ method: "get",
2084
+ path: "/api/v1/projects/{name}/export",
2085
+ summary: "Export a project as config",
2086
+ tags: ["projects"],
2087
+ parameters: [nameParameter],
2088
+ responses: {
2089
+ 200: { description: "Project configuration returned." },
2090
+ 404: { description: "Project not found." }
2091
+ }
2092
+ },
2093
+ {
2094
+ method: "get",
2095
+ path: "/api/v1/projects/{name}/keywords",
2096
+ summary: "List keywords",
2097
+ tags: ["keywords"],
2098
+ parameters: [nameParameter],
2099
+ responses: {
2100
+ 200: { description: "Keywords returned." }
2101
+ }
2102
+ },
2103
+ {
2104
+ method: "put",
2105
+ path: "/api/v1/projects/{name}/keywords",
2106
+ summary: "Replace keywords",
2107
+ tags: ["keywords"],
2108
+ parameters: [nameParameter],
2109
+ requestBody: {
2110
+ required: true,
2111
+ content: {
2112
+ "application/json": {
2113
+ schema: {
2114
+ type: "object",
2115
+ required: ["keywords"],
2116
+ properties: {
2117
+ keywords: stringArraySchema
2118
+ }
2119
+ }
2120
+ }
2121
+ }
2122
+ },
2123
+ responses: {
2124
+ 200: { description: "Keywords replaced." }
2125
+ }
2126
+ },
2127
+ {
2128
+ method: "post",
2129
+ path: "/api/v1/projects/{name}/keywords",
2130
+ summary: "Append keywords",
2131
+ tags: ["keywords"],
2132
+ parameters: [nameParameter],
2133
+ requestBody: {
2134
+ required: true,
2135
+ content: {
2136
+ "application/json": {
2137
+ schema: {
2138
+ type: "object",
2139
+ required: ["keywords"],
2140
+ properties: {
2141
+ keywords: stringArraySchema
2142
+ }
2143
+ }
2144
+ }
2145
+ }
2146
+ },
2147
+ responses: {
2148
+ 200: { description: "Keywords appended." }
2149
+ }
2150
+ },
2151
+ {
2152
+ method: "post",
2153
+ path: "/api/v1/projects/{name}/keywords/generate",
2154
+ summary: "Generate keyword suggestions",
2155
+ tags: ["keywords"],
2156
+ parameters: [nameParameter],
2157
+ requestBody: {
2158
+ required: true,
2159
+ content: {
2160
+ "application/json": {
2161
+ schema: {
2162
+ type: "object",
2163
+ required: ["provider"],
2164
+ properties: {
2165
+ provider: { type: "string", enum: ["gemini", "openai", "claude", "local"] },
2166
+ count: integerSchema
2167
+ }
2168
+ }
2169
+ }
2170
+ }
2171
+ },
2172
+ responses: {
2173
+ 200: { description: "Keyword suggestions returned." },
2174
+ 501: { description: "Keyword generation is not available." }
2175
+ }
2176
+ },
2177
+ {
2178
+ method: "get",
2179
+ path: "/api/v1/projects/{name}/competitors",
2180
+ summary: "List competitors",
2181
+ tags: ["competitors"],
2182
+ parameters: [nameParameter],
2183
+ responses: {
2184
+ 200: { description: "Competitors returned." }
2185
+ }
2186
+ },
2187
+ {
2188
+ method: "put",
2189
+ path: "/api/v1/projects/{name}/competitors",
2190
+ summary: "Replace competitors",
2191
+ tags: ["competitors"],
2192
+ parameters: [nameParameter],
2193
+ requestBody: {
2194
+ required: true,
2195
+ content: {
2196
+ "application/json": {
2197
+ schema: {
2198
+ type: "object",
2199
+ required: ["competitors"],
2200
+ properties: {
2201
+ competitors: stringArraySchema
2202
+ }
2203
+ }
2204
+ }
2205
+ }
2206
+ },
2207
+ responses: {
2208
+ 200: { description: "Competitors replaced." }
2209
+ }
2210
+ },
2211
+ {
2212
+ method: "post",
2213
+ path: "/api/v1/projects/{name}/runs",
2214
+ summary: "Trigger a project run",
2215
+ tags: ["runs"],
2216
+ parameters: [nameParameter],
2217
+ requestBody: {
2218
+ content: {
2219
+ "application/json": {
2220
+ schema: {
2221
+ type: "object",
2222
+ properties: {
2223
+ kind: stringSchema,
2224
+ trigger: stringSchema,
2225
+ providers: stringArraySchema
2226
+ }
2227
+ }
2228
+ }
2229
+ }
2230
+ },
2231
+ responses: {
2232
+ 201: { description: "Run queued." },
2233
+ 409: { description: "Run already in progress." }
2234
+ }
2235
+ },
2236
+ {
2237
+ method: "get",
2238
+ path: "/api/v1/projects/{name}/runs",
2239
+ summary: "List project runs",
2240
+ tags: ["runs"],
2241
+ parameters: [nameParameter],
2242
+ responses: {
2243
+ 200: { description: "Runs returned." }
2244
+ }
2245
+ },
2246
+ {
2247
+ method: "get",
2248
+ path: "/api/v1/runs",
2249
+ summary: "List all runs",
2250
+ tags: ["runs"],
2251
+ responses: {
2252
+ 200: { description: "Runs returned." }
2253
+ }
2254
+ },
2255
+ {
2256
+ method: "post",
2257
+ path: "/api/v1/runs",
2258
+ summary: "Trigger runs for all projects",
2259
+ tags: ["runs"],
2260
+ requestBody: {
2261
+ content: {
2262
+ "application/json": {
2263
+ schema: {
2264
+ type: "object",
2265
+ properties: {
2266
+ kind: stringSchema,
2267
+ providers: stringArraySchema
2268
+ }
2269
+ }
2270
+ }
2271
+ }
2272
+ },
2273
+ responses: {
2274
+ 207: { description: "Run results returned." }
2275
+ }
2276
+ },
2277
+ {
2278
+ method: "get",
2279
+ path: "/api/v1/runs/{id}",
2280
+ summary: "Get a run and its snapshots",
2281
+ tags: ["runs"],
2282
+ parameters: [runIdParameter],
2283
+ responses: {
2284
+ 200: { description: "Run returned." },
2285
+ 404: { description: "Run not found." }
2286
+ }
2287
+ },
2288
+ {
2289
+ method: "post",
2290
+ path: "/api/v1/apply",
2291
+ summary: "Apply a Canonry config document",
2292
+ tags: ["config"],
2293
+ requestBody: {
2294
+ required: true,
2295
+ description: "Canonry project configuration as JSON.",
2296
+ content: {
2297
+ "application/json": {
2298
+ schema: objectSchema
2299
+ }
2300
+ }
2301
+ },
2302
+ responses: {
2303
+ 200: { description: "Config applied." },
2304
+ 400: { description: "Invalid config." }
2305
+ }
2306
+ },
2307
+ {
2308
+ method: "get",
2309
+ path: "/api/v1/projects/{name}/history",
2310
+ summary: "Get project audit history",
2311
+ tags: ["history"],
2312
+ parameters: [nameParameter],
2313
+ responses: {
2314
+ 200: { description: "Audit history returned." }
2315
+ }
2316
+ },
2317
+ {
2318
+ method: "get",
2319
+ path: "/api/v1/history",
2320
+ summary: "Get global audit history",
2321
+ tags: ["history"],
2322
+ responses: {
2323
+ 200: { description: "Audit history returned." }
2324
+ }
2325
+ },
2326
+ {
2327
+ method: "get",
2328
+ path: "/api/v1/projects/{name}/snapshots",
2329
+ summary: "List query snapshots",
2330
+ tags: ["history"],
2331
+ parameters: [
2332
+ nameParameter,
2333
+ {
2334
+ name: "limit",
2335
+ in: "query",
2336
+ description: "Maximum number of snapshots to return.",
2337
+ schema: integerSchema
2338
+ },
2339
+ {
2340
+ name: "offset",
2341
+ in: "query",
2342
+ description: "Number of snapshots to skip.",
2343
+ schema: integerSchema
2344
+ }
2345
+ ],
2346
+ responses: {
2347
+ 200: { description: "Snapshots returned." }
2348
+ }
2349
+ },
2350
+ {
2351
+ method: "get",
2352
+ path: "/api/v1/projects/{name}/timeline",
2353
+ summary: "Get keyword timeline",
2354
+ tags: ["history"],
2355
+ parameters: [nameParameter],
2356
+ responses: {
2357
+ 200: { description: "Timeline returned." }
2358
+ }
2359
+ },
2360
+ {
2361
+ method: "get",
2362
+ path: "/api/v1/projects/{name}/snapshots/diff",
2363
+ summary: "Compare two runs",
2364
+ tags: ["history"],
2365
+ parameters: [
2366
+ nameParameter,
2367
+ {
2368
+ name: "run1",
2369
+ in: "query",
2370
+ required: true,
2371
+ description: "First run ID.",
2372
+ schema: stringSchema
2373
+ },
2374
+ {
2375
+ name: "run2",
2376
+ in: "query",
2377
+ required: true,
2378
+ description: "Second run ID.",
2379
+ schema: stringSchema
2380
+ }
2381
+ ],
2382
+ responses: {
2383
+ 200: { description: "Diff returned." },
2384
+ 400: { description: "Missing run IDs." }
2385
+ }
2386
+ },
2387
+ {
2388
+ method: "get",
2389
+ path: "/api/v1/settings",
2390
+ summary: "Get provider settings summary",
2391
+ tags: ["settings"],
2392
+ responses: {
2393
+ 200: { description: "Settings returned." }
2394
+ }
2395
+ },
2396
+ {
2397
+ method: "put",
2398
+ path: "/api/v1/settings/providers/{name}",
2399
+ summary: "Update provider settings",
2400
+ tags: ["settings"],
2401
+ parameters: [providerNameParameter],
2402
+ requestBody: {
2403
+ required: true,
2404
+ content: {
2405
+ "application/json": {
2406
+ schema: {
2407
+ type: "object",
2408
+ properties: {
2409
+ apiKey: stringSchema,
2410
+ baseUrl: stringSchema,
2411
+ model: stringSchema,
2412
+ quota: objectSchema
2413
+ }
2414
+ }
2415
+ }
2416
+ }
2417
+ },
2418
+ responses: {
2419
+ 200: { description: "Provider updated." },
2420
+ 400: { description: "Invalid provider settings." },
2421
+ 501: { description: "Provider updates are not supported." }
2422
+ }
2423
+ },
2424
+ {
2425
+ method: "put",
2426
+ path: "/api/v1/projects/{name}/schedule",
2427
+ summary: "Create or update a schedule",
2428
+ tags: ["schedules"],
2429
+ parameters: [nameParameter],
2430
+ requestBody: {
2431
+ required: true,
2432
+ content: {
2433
+ "application/json": {
2434
+ schema: {
2435
+ type: "object",
2436
+ properties: {
2437
+ preset: stringSchema,
2438
+ cron: stringSchema,
2439
+ timezone: stringSchema,
2440
+ providers: stringArraySchema,
2441
+ enabled: booleanSchema
2442
+ }
2443
+ }
2444
+ }
2445
+ }
2446
+ },
2447
+ responses: {
2448
+ 200: { description: "Schedule updated." },
2449
+ 201: { description: "Schedule created." }
2450
+ }
2451
+ },
2452
+ {
2453
+ method: "get",
2454
+ path: "/api/v1/projects/{name}/schedule",
2455
+ summary: "Get a schedule",
2456
+ tags: ["schedules"],
2457
+ parameters: [nameParameter],
2458
+ responses: {
2459
+ 200: { description: "Schedule returned." },
2460
+ 404: { description: "Schedule not found." }
2461
+ }
2462
+ },
2463
+ {
2464
+ method: "delete",
2465
+ path: "/api/v1/projects/{name}/schedule",
2466
+ summary: "Delete a schedule",
2467
+ tags: ["schedules"],
2468
+ parameters: [nameParameter],
2469
+ responses: {
2470
+ 204: { description: "Schedule deleted." },
2471
+ 404: { description: "Schedule not found." }
2472
+ }
2473
+ },
2474
+ {
2475
+ method: "get",
2476
+ path: "/api/v1/notifications/events",
2477
+ summary: "List notification event types",
2478
+ tags: ["notifications"],
2479
+ responses: {
2480
+ 200: { description: "Events returned." }
2481
+ }
2482
+ },
2483
+ {
2484
+ method: "post",
2485
+ path: "/api/v1/projects/{name}/notifications",
2486
+ summary: "Create a notification",
2487
+ tags: ["notifications"],
2488
+ parameters: [nameParameter],
2489
+ requestBody: {
2490
+ required: true,
2491
+ content: {
2492
+ "application/json": {
2493
+ schema: {
2494
+ type: "object",
2495
+ required: ["channel", "url", "events"],
2496
+ properties: {
2497
+ channel: stringSchema,
2498
+ url: stringSchema,
2499
+ events: stringArraySchema
2500
+ }
2501
+ }
2502
+ }
2503
+ }
2504
+ },
2505
+ responses: {
2506
+ 201: { description: "Notification created." }
2507
+ }
2508
+ },
2509
+ {
2510
+ method: "get",
2511
+ path: "/api/v1/projects/{name}/notifications",
2512
+ summary: "List notifications",
2513
+ tags: ["notifications"],
2514
+ parameters: [nameParameter],
2515
+ responses: {
2516
+ 200: { description: "Notifications returned." }
2517
+ }
2518
+ },
2519
+ {
2520
+ method: "delete",
2521
+ path: "/api/v1/projects/{name}/notifications/{id}",
2522
+ summary: "Delete a notification",
2523
+ tags: ["notifications"],
2524
+ parameters: [nameParameter, notificationIdParameter],
2525
+ responses: {
2526
+ 204: { description: "Notification deleted." },
2527
+ 404: { description: "Notification not found." }
2528
+ }
2529
+ },
2530
+ {
2531
+ method: "post",
2532
+ path: "/api/v1/projects/{name}/notifications/{id}/test",
2533
+ summary: "Send a test notification",
2534
+ tags: ["notifications"],
2535
+ parameters: [nameParameter, notificationIdParameter],
2536
+ responses: {
2537
+ 200: { description: "Test notification sent." },
2538
+ 400: { description: "Stored notification config is invalid." },
2539
+ 404: { description: "Notification not found." },
2540
+ 502: { description: "Notification delivery failed." }
2541
+ }
2542
+ },
2543
+ {
2544
+ method: "get",
2545
+ path: "/api/v1/telemetry",
2546
+ summary: "Get telemetry status",
2547
+ tags: ["telemetry"],
2548
+ responses: {
2549
+ 200: { description: "Telemetry status returned." },
2550
+ 501: { description: "Telemetry status is not available." }
2551
+ }
2552
+ },
2553
+ {
2554
+ method: "put",
2555
+ path: "/api/v1/telemetry",
2556
+ summary: "Update telemetry status",
2557
+ tags: ["telemetry"],
2558
+ requestBody: {
2559
+ required: true,
2560
+ content: {
2561
+ "application/json": {
2562
+ schema: {
2563
+ type: "object",
2564
+ required: ["enabled"],
2565
+ properties: {
2566
+ enabled: booleanSchema
2567
+ }
2568
+ }
2569
+ }
2570
+ }
2571
+ },
2572
+ responses: {
2573
+ 200: { description: "Telemetry updated." },
2574
+ 400: { description: "Invalid telemetry request." },
2575
+ 501: { description: "Telemetry configuration is not available." }
2576
+ }
2577
+ }
2578
+ ];
2579
+ function buildOpenApiDocument(info = {}) {
2580
+ const paths = routeCatalog.reduce((acc, route) => {
2581
+ const operation = {
2582
+ summary: route.summary,
2583
+ tags: route.tags,
2584
+ responses: route.responses,
2585
+ operationId: buildOperationId(route.method, route.path)
2586
+ };
2587
+ if (route.description) operation.description = route.description;
2588
+ if (route.parameters) operation.parameters = route.parameters;
2589
+ if (route.requestBody) operation.requestBody = route.requestBody;
2590
+ if (route.auth === false) operation.security = [];
2591
+ const pathItem = acc[route.path] ?? {};
2592
+ pathItem[route.method] = operation;
2593
+ acc[route.path] = pathItem;
2594
+ return acc;
2595
+ }, {});
2596
+ return {
2597
+ openapi: "3.1.0",
2598
+ info: {
2599
+ title: info.title ?? "Canonry API",
2600
+ version: info.version ?? "0.0.0",
2601
+ description: info.description ?? "REST API for Canonry projects, runs, schedules, and notifications."
2602
+ },
2603
+ servers: [
2604
+ {
2605
+ url: "/"
2606
+ }
2607
+ ],
2608
+ security: [{ bearerAuth: [] }],
2609
+ components: {
2610
+ securitySchemes: {
2611
+ bearerAuth: {
2612
+ type: "http",
2613
+ scheme: "bearer",
2614
+ bearerFormat: "API key"
2615
+ }
2616
+ }
2617
+ },
2618
+ paths
2619
+ };
2620
+ }
2621
+ async function openApiRoutes(app, opts = {}) {
2622
+ app.get("/openapi.json", async (_request, reply) => {
2623
+ return reply.type("application/json").send(buildOpenApiDocument(opts));
2624
+ });
2625
+ }
2626
+ function buildOperationId(method, path3) {
2627
+ const parts = path3.split("/").filter(Boolean).map((part) => {
2628
+ if (part.startsWith("{") && part.endsWith("}")) {
2629
+ return `by-${part.slice(1, -1)}`;
2630
+ }
2631
+ return part;
2632
+ });
2633
+ return [method, ...parts].join("-").replace(/[^a-zA-Z0-9]+(.)/g, (_match, char) => char.toUpperCase()).replace(/^[^a-zA-Z]+/, "");
2634
+ }
2635
+
1932
2636
  // ../api-routes/src/settings.ts
1933
2637
  async function settingsRoutes(app, opts) {
1934
2638
  app.get("/settings", async () => ({
@@ -2307,6 +3011,7 @@ async function apiRoutes(app, opts) {
2307
3011
  await app.register(authPlugin);
2308
3012
  }
2309
3013
  await app.register(async (api) => {
3014
+ await api.register(openApiRoutes, opts.openApiInfo ?? {});
2310
3015
  await api.register(projectRoutes, {
2311
3016
  onProjectDeleted: opts.onProjectDeleted
2312
3017
  });
@@ -3315,17 +4020,21 @@ var JobRunner = class {
3315
4020
  minuteWindows.get(providerName),
3316
4021
  config.quotaPolicy.maxRequestsPerMinute
3317
4022
  );
4023
+ const allDomains = effectiveDomains({
4024
+ canonicalDomain: project.canonicalDomain,
4025
+ ownedDomains: JSON.parse(project.ownedDomains || "[]")
4026
+ });
3318
4027
  const raw = await adapter.executeTrackedQuery(
3319
4028
  {
3320
4029
  keyword: kw.keyword,
3321
- canonicalDomains: [project.canonicalDomain],
4030
+ canonicalDomains: allDomains,
3322
4031
  competitorDomains
3323
4032
  },
3324
4033
  config
3325
4034
  );
3326
4035
  const normalized = adapter.normalizeResult(raw);
3327
- console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, canonical="${project.canonicalDomain}"`);
3328
- const citationState = determineCitationState(normalized, project.canonicalDomain);
4036
+ console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map((s) => s.uri))}, domains=${JSON.stringify(allDomains)}`);
4037
+ const citationState = determineCitationState(normalized, allDomains);
3329
4038
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
3330
4039
  this.db.insert(querySnapshots).values({
3331
4040
  id: crypto12.randomUUID(),
@@ -3444,39 +4153,31 @@ function getCurrentPeriod() {
3444
4153
  const d = /* @__PURE__ */ new Date();
3445
4154
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
3446
4155
  }
3447
- function normalizeDomain(input) {
3448
- let domain = input;
3449
- try {
3450
- if (domain.includes("://")) {
3451
- domain = new URL(domain).hostname;
3452
- }
3453
- } catch {
3454
- }
3455
- return domain.replace(/^www\./, "");
3456
- }
3457
4156
  function domainMatches(domain, canonicalDomain) {
3458
- const normalized = normalizeDomain(canonicalDomain);
3459
- const d = normalizeDomain(domain);
4157
+ const normalized = normalizeProjectDomain(canonicalDomain);
4158
+ const d = normalizeProjectDomain(domain);
3460
4159
  return d === normalized || d.endsWith(`.${normalized}`);
3461
4160
  }
3462
- function determineCitationState(normalized, canonicalDomain) {
3463
- const bareDomain = normalizeDomain(canonicalDomain);
3464
- if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
3465
- return "cited";
3466
- }
3467
- const lowerDomain = bareDomain.toLowerCase();
3468
- for (const source of normalized.groundingSources) {
3469
- try {
3470
- const uri = source.uri.toLowerCase();
3471
- if (uri.includes(lowerDomain)) {
3472
- return "cited";
3473
- }
3474
- } catch {
4161
+ function determineCitationState(normalized, domains) {
4162
+ for (const canonicalDomain of domains) {
4163
+ const bareDomain = normalizeProjectDomain(canonicalDomain);
4164
+ if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
4165
+ return "cited";
3475
4166
  }
3476
- if (source.title) {
3477
- const titleLower = source.title.toLowerCase().replace(/^www\./, "");
3478
- if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
3479
- return "cited";
4167
+ const lowerDomain = bareDomain.toLowerCase();
4168
+ for (const source of normalized.groundingSources) {
4169
+ try {
4170
+ const uri = source.uri.toLowerCase();
4171
+ if (lowerDomain.includes(".") && uri.includes(lowerDomain)) {
4172
+ return "cited";
4173
+ }
4174
+ } catch {
4175
+ }
4176
+ if (source.title) {
4177
+ const titleLower = source.title.toLowerCase().replace(/^www\./, "");
4178
+ if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
4179
+ return "cited";
4180
+ }
3480
4181
  }
3481
4182
  }
3482
4183
  }
@@ -4026,6 +4727,10 @@ async function createServer(opts) {
4026
4727
  await app.register(apiRoutes, {
4027
4728
  db: opts.db,
4028
4729
  skipAuth: false,
4730
+ openApiInfo: {
4731
+ title: "Canonry API",
4732
+ version: PKG_VERSION
4733
+ },
4029
4734
  providerSummary,
4030
4735
  onRunCreated: (runId, projectId, providers2) => {
4031
4736
  jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
@@ -4205,6 +4910,7 @@ function parseKeywordResponse(raw, count) {
4205
4910
  export {
4206
4911
  providerQuotaPolicySchema,
4207
4912
  notificationEventSchema,
4913
+ effectiveDomains,
4208
4914
  apiKeys,
4209
4915
  createClient,
4210
4916
  migrate,