@ainyc/canonry 4.190.0 → 4.191.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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/assets/agent-workspace/skills/canonry/SKILL.md +124 -216
  3. package/assets/assets/{AuditHistoryPanel-wWLs088f.js → AuditHistoryPanel-C2dvjYNh.js} +1 -1
  4. package/assets/assets/{BacklinksPage-DJEO5o9c.js → BacklinksPage-D1yPQtfF.js} +1 -1
  5. package/assets/assets/{HistoryPage-CV5bTBU6.js → HistoryPage-DOEa7naA.js} +1 -1
  6. package/assets/assets/{MeasurementPropertyPage-C7KLIAad.js → MeasurementPropertyPage-D-O8vnMS.js} +1 -1
  7. package/assets/assets/ProjectPage-DqTCcimz.js +8 -0
  8. package/assets/assets/{RunRow-C3Ryj_9u.js → RunRow-BO7lyC1C.js} +1 -1
  9. package/assets/assets/{RunsPage-BxoEkZu3.js → RunsPage-DHSWfTav.js} +1 -1
  10. package/assets/assets/{SettingsPage-D_mB1JUZ.js → SettingsPage-oiJdhiq0.js} +1 -1
  11. package/assets/assets/SiteHealthSection-gVdylFY-.js +4 -0
  12. package/assets/assets/{TrafficPage-DgsWuDtv.js → TrafficPage-YIdwRpQh.js} +1 -1
  13. package/assets/assets/{TrafficSourceDetailPage-BLWlQ8aT.js → TrafficSourceDetailPage-B4Cop6ox.js} +1 -1
  14. package/assets/assets/{extract-error-message-DHIHApUf.js → extract-error-message-XavUZNf3.js} +1 -1
  15. package/assets/assets/{index-BjjM151x.css → index-BtTEcbcB.css} +1 -1
  16. package/assets/assets/{index-nb8QHq99.js → index-njfYnO22.js} +25 -25
  17. package/assets/assets/{react-sigma_core.esm.min-DzTADS1A.js → react-sigma_core.esm.min-V7giUpEn.js} +1 -1
  18. package/assets/index.html +2 -2
  19. package/dist/{chunk-32QBVRMN.js → chunk-2A7E5TDU.js} +209 -29
  20. package/dist/{chunk-ROPJUNQN.js → chunk-KBUV3HGS.js} +55 -9
  21. package/dist/{chunk-2II4ZMMH.js → chunk-WSD5J6O4.js} +2 -2
  22. package/dist/{chunk-4NIQ3GMO.js → chunk-XI6FN52S.js} +33 -13
  23. package/dist/{chunk-6POEZ7GT.js → chunk-ZK4FJDCH.js} +291 -114
  24. package/dist/cli.js +56 -5
  25. package/dist/index.js +4 -4
  26. package/dist/{intelligence-service-WNASQFN4.js → intelligence-service-EVRWZHDG.js} +2 -2
  27. package/dist/mcp.js +13 -8
  28. package/package.json +11 -11
  29. package/assets/assets/ProjectPage-DIW1h05b.js +0 -8
  30. package/assets/assets/SiteHealthSection-Dgqtz9J2.js +0 -4
@@ -88,6 +88,7 @@ import {
88
88
  MeasurementRunScopeError,
89
89
  PROJECTS_WRITE_SCOPE,
90
90
  READ_ONLY_SCOPE,
91
+ RESEARCH_RUN_SCOPE,
91
92
  ResearchQueryStatuses,
92
93
  ResearchRunStatuses,
93
94
  RunKinds,
@@ -381,6 +382,7 @@ import {
381
382
  inclusiveDayCount,
382
383
  indexingRequestResponseDtoSchema,
383
384
  internalError,
385
+ intersectScopes,
384
386
  isBrowserProvider,
385
387
  isLocationRedirectStatus,
386
388
  isReadOnlyKey,
@@ -560,6 +562,8 @@ import {
560
562
  reportConfidenceLabel,
561
563
  reportHorizonLabel,
562
564
  reportSeverityLabel,
565
+ researchBatchCreateSchema,
566
+ researchBatchSchema,
563
567
  researchDailyLimitExceeded,
564
568
  researchRunCreateSchema,
565
569
  researchRunDetailSchema,
@@ -570,6 +574,7 @@ import {
570
574
  resolveMeasurementRunQueryScope,
571
575
  resolveMeasurementRunScope,
572
576
  resolveSnapshotRequestQueries,
577
+ restrictedWriteScopes,
573
578
  resultsClearRequestSchema,
574
579
  resultsClearResponseSchema,
575
580
  resultsExportDtoSchema,
@@ -676,7 +681,7 @@ import {
676
681
  wordpressSchemaDeployResultDtoSchema,
677
682
  wordpressSchemaStatusResultDtoSchema,
678
683
  wordpressStatusDtoSchema
679
- } from "./chunk-ROPJUNQN.js";
684
+ } from "./chunk-KBUV3HGS.js";
680
685
 
681
686
  // src/intelligence-service.ts
682
687
  import { eq as eq67, desc as desc29, asc as asc12, and as and57, ne as ne10, or as or14, inArray as inArray25, gte as gte18, lte as lte15, isNull as isNull12, sql as sql26, exists } from "drizzle-orm";
@@ -1181,6 +1186,8 @@ var apiKeys = sqliteTable("api_keys", {
1181
1186
  * project so a stale scoped key never outlives its project.
1182
1187
  */
1183
1188
  projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
1189
+ /** Internal delegated session identity, never accepted by the key-create API. */
1190
+ delegatedUserId: text("delegated_user_id").references(() => users.id, { onDelete: "cascade" }),
1184
1191
  createdAt: text("created_at").notNull(),
1185
1192
  lastUsedAt: text("last_used_at"),
1186
1193
  revokedAt: text("revoked_at")
@@ -7177,6 +7184,13 @@ var MIGRATION_VERSIONS = [
7177
7184
  `ALTER TABLE research_runs ADD COLUMN template TEXT`,
7178
7185
  `ALTER TABLE research_run_queries ADD COLUMN query_class TEXT`
7179
7186
  ]
7187
+ },
7188
+ {
7189
+ version: 154,
7190
+ name: "delegated-api-key-user",
7191
+ statements: [
7192
+ `ALTER TABLE api_keys ADD COLUMN delegated_user_id TEXT REFERENCES users(id) ON DELETE CASCADE`
7193
+ ]
7180
7194
  }
7181
7195
  ];
7182
7196
  function addRunsMeasurementPlanVersionForeignKey(tx) {
@@ -10795,10 +10809,6 @@ var ADS_MUTATION_SCOPES = /* @__PURE__ */ new Set([
10795
10809
  ADS_APPROVE_SCOPE,
10796
10810
  ADS_ACTIVATE_SCOPE
10797
10811
  ]);
10798
- function isAdsMutationOnlyKey(scopes) {
10799
- const writeGrants = scopes.filter((scope) => scope === "*" || scope === "write" || scope.endsWith(".write") || scope === ADS_APPROVE_SCOPE || scope === ADS_ACTIVATE_SCOPE);
10800
- return writeGrants.length > 0 && writeGrants.every((scope) => ADS_MUTATION_SCOPES.has(scope));
10801
- }
10802
10812
  function isAdsWriteRoute(url) {
10803
10813
  const rest = projectRouteRest(url);
10804
10814
  return rest !== null && rest.startsWith("ads/");
@@ -10829,8 +10839,8 @@ var VIEWER_DENIED_MESSAGE = "Your account has view-only access, so it cannot mak
10829
10839
  var ADMIN_ONLY_MESSAGE = "Only an administrator account can use this.";
10830
10840
  function requireAdminSession(request) {
10831
10841
  const principal = request.principal;
10832
- if (!principal || principal.kind !== "user") return;
10833
- if (principal.role === UserRoles.admin) return;
10842
+ const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
10843
+ if (!role || role === UserRoles.admin) return;
10834
10844
  throw forbidden(ADMIN_ONLY_MESSAGE);
10835
10845
  }
10836
10846
  function requirePaidReadScope(request) {
@@ -10841,16 +10851,20 @@ function requirePaidReadScope(request) {
10841
10851
  throw forbidden("This API key was not granted access to OpenAI Ads paid reads.");
10842
10852
  }
10843
10853
  }
10854
+ function canRunResearch(request, allowViewers) {
10855
+ const principal = request.principal;
10856
+ if (!principal) return true;
10857
+ if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) return false;
10858
+ return principal.scopes.includes(WILDCARD_SCOPE) || principal.scopes.includes(RESEARCH_RUN_SCOPE);
10859
+ }
10844
10860
  function requireResearchGrant(request, allowViewers) {
10845
10861
  const principal = request.principal;
10846
10862
  if (!principal) return;
10847
- if (principal.kind === "api-key") {
10848
- if (principal.scopes.includes(WILDCARD_SCOPE)) return;
10849
- throw forbidden("This API key was not granted access to paid research queries.");
10863
+ if (canRunResearch(request, allowViewers)) return;
10864
+ if (principal.kind === "user" && principal.role === UserRoles.viewer && !allowViewers) {
10865
+ throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
10850
10866
  }
10851
- if (principal.role === UserRoles.admin) return;
10852
- if (principal.role === UserRoles.viewer && allowViewers) return;
10853
- throw forbidden("Research queries are not enabled for viewer accounts on this deployment.");
10867
+ throw forbidden('This credential was not granted the "research.run" capability.');
10854
10868
  }
10855
10869
  function requireBroadInstanceKey(request) {
10856
10870
  const principal = request.principal;
@@ -10893,8 +10907,8 @@ function shouldSkipAuth(url) {
10893
10907
  }
10894
10908
  var USERS_READ_SCOPE = "users.read";
10895
10909
  var USERS_WRITE_SCOPE = "users.write";
10896
- function scopesForRole(role) {
10897
- return role === UserRoles.admin ? [WILDCARD_SCOPE] : [READ_ONLY_SCOPE];
10910
+ function scopesForRole(role, researchAllowViewers = false) {
10911
+ return role === UserRoles.admin ? [WILDCARD_SCOPE] : researchAllowViewers ? [READ_ONLY_SCOPE, RESEARCH_RUN_SCOPE] : [READ_ONLY_SCOPE];
10898
10912
  }
10899
10913
  function queryValue(request, key) {
10900
10914
  const raw = request.query?.[key];
@@ -10984,7 +10998,7 @@ function enforceEmbedProjectTabs(request, configuredTabs) {
10984
10998
  if (tabs.includes("report") && isReportRead(url)) return;
10985
10999
  throw forbidden("This endpoint is not available for the configured embed tabs.");
10986
11000
  }
10987
- function resolveSignedInPerson(app, request, reply, cookie) {
11001
+ function resolveSignedInPerson(app, request, reply, cookie, researchAllowViewers) {
10988
11002
  const sessionId = parseCookieHeader(request.headers.cookie)[USER_SESSION_COOKIE_NAME];
10989
11003
  if (!sessionId) return false;
10990
11004
  const resolved = resolveUserSession(app.db, sessionId);
@@ -10993,7 +11007,7 @@ function resolveSignedInPerson(app, request, reply, cookie) {
10993
11007
  kind: "user",
10994
11008
  id: resolved.user.id,
10995
11009
  name: resolved.user.name,
10996
- scopes: scopesForRole(resolved.user.role),
11010
+ scopes: scopesForRole(resolved.user.role, researchAllowViewers),
10997
11011
  projectId: null,
10998
11012
  role: resolved.user.role,
10999
11013
  viaCookie: true
@@ -11009,12 +11023,24 @@ function resolveSignedInPerson(app, request, reply, cookie) {
11009
11023
  }
11010
11024
  function applyRoleGates(request) {
11011
11025
  const principal = request.principal;
11012
- if (!principal || principal.kind !== "user") return;
11013
- request.readSemanticGrant = principal.role === UserRoles.viewer && isReadSemanticRoute(request);
11026
+ const role = principal?.kind === "user" ? principal.role : principal?.delegatedUser?.role;
11027
+ if (!principal || !role) return;
11028
+ request.readSemanticGrant = role === UserRoles.viewer && isReadSemanticRoute(request);
11029
+ applyScopedWriteGates(request);
11014
11030
  if (isReadOnlyKey(principal.scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isPaidReadRoute(request) && !isTransportEnvelopeRoute(request)) {
11015
11031
  throw forbidden(VIEWER_DENIED_MESSAGE);
11016
11032
  }
11017
11033
  }
11034
+ function applyScopedWriteGates(request) {
11035
+ if (!WRITE_METHODS2.has(request.method) || isTransportEnvelopeRoute(request) || request.readSemanticGrant) return;
11036
+ const scopes = principalScopes(request) ?? [];
11037
+ const restricted = restrictedWriteScopes(scopes);
11038
+ if (!restricted) return;
11039
+ const required2 = request.routeOptions.config.writeScope;
11040
+ if (required2 && restricted.includes(required2)) return;
11041
+ if (restricted.some((scope) => ADS_MUTATION_SCOPES.has(scope)) && isAdsWriteRoute(request.url.split("?")[0])) return;
11042
+ throw forbidden("This credential can only perform operations explicitly granted by its scopes.");
11043
+ }
11018
11044
  async function authPlugin(app, opts = {}) {
11019
11045
  app.addHook("onRequest", async (request, reply) => {
11020
11046
  const url = request.url.split("?")[0];
@@ -11035,10 +11061,9 @@ async function authPlugin(app, opts = {}) {
11035
11061
  if (granted) {
11036
11062
  const account = app.db.select().from(users).where(eq2(users.id, granted.userId)).get();
11037
11063
  if (!account) throw authInvalid();
11038
- const roleScopes = scopesForRole(account.role);
11064
+ const roleScopes = scopesForRole(account.role, opts.researchAllowViewers);
11039
11065
  const requested = (granted.scope ?? "").split(/\s+/).filter((part) => part.length > 0 && part !== "offline_access");
11040
- const narrowed = roleScopes.includes(WILDCARD_SCOPE) ? requested.length > 0 ? requested : [READ_ONLY_SCOPE] : roleScopes.filter((scope) => requested.includes(scope) || requested.includes(WILDCARD_SCOPE));
11041
- const effective = narrowed.length > 0 ? narrowed : [READ_ONLY_SCOPE];
11066
+ const effective = intersectScopes(roleScopes, requested);
11042
11067
  request.principal = {
11043
11068
  kind: "user",
11044
11069
  id: account.id,
@@ -11055,7 +11080,7 @@ async function authPlugin(app, opts = {}) {
11055
11080
  }
11056
11081
  throw authInvalid();
11057
11082
  }
11058
- } else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie)) {
11083
+ } else if (resolveSignedInPerson(app, request, reply, opts.userSessionCookie, opts.researchAllowViewers ?? false)) {
11059
11084
  applyRoleGates(request);
11060
11085
  assertSameOriginWrite(request);
11061
11086
  enforceEmbedProjectTabs(request, opts.embedProjectTabs);
@@ -11078,7 +11103,10 @@ async function authPlugin(app, opts = {}) {
11078
11103
  throw authRequired();
11079
11104
  }
11080
11105
  app.db.update(apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq2(apiKeys.id, key.id)).run();
11081
- const scopes = Array.isArray(key.scopes) ? key.scopes : [];
11106
+ let scopes = Array.isArray(key.scopes) ? key.scopes : [];
11107
+ const delegatedUser = key.delegatedUserId ? app.db.select().from(users).where(eq2(users.id, key.delegatedUserId)).get() : void 0;
11108
+ if (key.delegatedUserId && !delegatedUser) throw authInvalid();
11109
+ if (delegatedUser) scopes = intersectScopes(scopesForRole(delegatedUser.role, opts.researchAllowViewers), scopes);
11082
11110
  request.apiKey = { id: key.id, name: key.name, scopes, projectId: key.projectId ?? null };
11083
11111
  request.principal = {
11084
11112
  kind: "api-key",
@@ -11086,15 +11114,15 @@ async function authPlugin(app, opts = {}) {
11086
11114
  name: key.name,
11087
11115
  scopes,
11088
11116
  projectId: key.projectId ?? null,
11089
- viaCookie: keyArrivedInCookie
11117
+ viaCookie: keyArrivedInCookie,
11118
+ ...delegatedUser ? { delegatedUser: { id: delegatedUser.id, name: delegatedUser.name, role: delegatedUser.role } } : {}
11090
11119
  };
11091
11120
  assertSameOriginWrite(request);
11092
- if (isReadOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !isTransportEnvelopeRoute(request)) {
11121
+ applyRoleGates(request);
11122
+ applyScopedWriteGates(request);
11123
+ if (isReadOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !request.readSemanticGrant && !isTransportEnvelopeRoute(request)) {
11093
11124
  throw forbidden("This API key is read-only and cannot perform write operations.");
11094
11125
  }
11095
- if (isAdsMutationOnlyKey(scopes) && WRITE_METHODS2.has(request.method) && !isAdsWriteRoute(url)) {
11096
- throw forbidden("This API key can only perform OpenAI Ads write operations.");
11097
- }
11098
11126
  enforceEmbedProjectTabs(request, opts.embedProjectTabs);
11099
11127
  if (key.projectId) {
11100
11128
  const match = url.match(/\/projects\/([^/?#]+)/);
@@ -11149,7 +11177,7 @@ function redirectUriAllowed(registered, presented) {
11149
11177
  function isLoopbackHost(hostname) {
11150
11178
  return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
11151
11179
  }
11152
- var SUPPORTED_SCOPES = ["read", "offline_access"];
11180
+ var SUPPORTED_SCOPES = ["read", RESEARCH_RUN_SCOPE, "offline_access"];
11153
11181
  function resolveRequestedScope(requested) {
11154
11182
  if (!requested || !requested.trim()) return "read";
11155
11183
  const parts = requested.trim().split(/\s+/);
@@ -11183,6 +11211,7 @@ function consentPage(o) {
11183
11211
  <dt>Permissions</dt><dd>${escapeHtml(o.scope)}</dd>
11184
11212
  <dt>Redirects to</dt><dd class="uri">${escapeHtml(o.redirectUri)}</dd>
11185
11213
  </dl>
11214
+ ${o.scope.split(/\s+/).includes(RESEARCH_RUN_SCOPE) ? `<p class="warn">Run research queries using the operator's provider quota, subject to the shared daily research limit. This does not grant permission to change tracking or settings.</p>` : ""}
11186
11215
  <input type="hidden" name="csrf" value="${escapeHtml(o.csrf)}">
11187
11216
  <div class="row">
11188
11217
  <button type="submit" name="approve" value="no" class="ghost">Deny</button>
@@ -13805,54 +13834,61 @@ function scopeNamedShareOfVoice(identities, answered2, prepared) {
13805
13834
  entries: credited.map((row) => ({ ...row, share: row.credits / denominator }))
13806
13835
  };
13807
13836
  }
13808
- function buildMeasurementOverview(input, options = {}) {
13837
+ function createMeasurementOverviewEvaluator(input, options = {}) {
13809
13838
  const prepared = prepareReport(input, options);
13810
13839
  const indexes = buildMeasurementOverviewIndexes(input, prepared, options.onEvidenceIndexed);
13811
- const targetIds = new Set(input.scopeTargetIds);
13812
- const edges = indexedScopeEdges(indexes, targetIds);
13813
- const slots = indexedSlotsForEdges(edges, indexes);
13814
- const answered2 = indexedAnsweredSlots(slots, indexes);
13815
- const properties = sortedUnique([...targetIds]).map((targetId) => {
13816
- const ownEdges = indexes.targetEdgesByTargetId.get(targetId) ?? [];
13817
- const ownSlots = indexedSlotsForEdges(ownEdges, indexes);
13818
- const ownAnswered = indexedAnsweredSlots(ownSlots, indexes);
13819
- const ownTargets = indexes.targetsById.get(targetId);
13820
- return {
13821
- targetId,
13822
- mentionCoverage: targetMentionRate(ownTargets, ownSlots, ownAnswered, indexes),
13823
- citationCoverage: indexedScopeCitationRate(ownSlots, ownEdges, indexes),
13824
- // Each engine is measured over the slots it owns, using exactly the
13825
- // functions the Property total uses. A per-engine reading is therefore
13826
- // withheld for the same reasons the total is, never rounded down to zero.
13827
- providers: providersFor(ownSlots).map((provider) => {
13828
- const providerSlots = ownSlots.filter((slot) => slot.provider === provider);
13840
+ return {
13841
+ evaluate(scopeTargetIds, namedIdentities = input.namedIdentities) {
13842
+ const targetIds = new Set(scopeTargetIds);
13843
+ const edges = indexedScopeEdges(indexes, targetIds);
13844
+ const slots = indexedSlotsForEdges(edges, indexes);
13845
+ const answered2 = indexedAnsweredSlots(slots, indexes);
13846
+ const properties = sortedUnique([...targetIds]).map((targetId) => {
13847
+ const ownEdges = indexes.targetEdgesByTargetId.get(targetId) ?? [];
13848
+ const ownSlots = indexedSlotsForEdges(ownEdges, indexes);
13849
+ const ownAnswered = indexedAnsweredSlots(ownSlots, indexes);
13850
+ const ownTargets = indexes.targetsById.get(targetId);
13829
13851
  return {
13830
- provider,
13831
- mentionCoverage: targetMentionRate(
13832
- ownTargets,
13833
- providerSlots,
13834
- indexedAnsweredSlots(providerSlots, indexes),
13835
- indexes
13836
- ),
13837
- citationCoverage: indexedScopeCitationRate(providerSlots, ownEdges, indexes)
13852
+ targetId,
13853
+ mentionCoverage: targetMentionRate(ownTargets, ownSlots, ownAnswered, indexes),
13854
+ citationCoverage: indexedScopeCitationRate(ownSlots, ownEdges, indexes),
13855
+ // Each engine is measured over the slots it owns, using exactly the
13856
+ // functions the Property total uses. A per-engine reading is therefore
13857
+ // withheld for the same reasons the total is, never rounded down to zero.
13858
+ providers: providersFor(ownSlots).map((provider) => {
13859
+ const providerSlots = ownSlots.filter((slot) => slot.provider === provider);
13860
+ return {
13861
+ provider,
13862
+ mentionCoverage: targetMentionRate(
13863
+ ownTargets,
13864
+ providerSlots,
13865
+ indexedAnsweredSlots(providerSlots, indexes),
13866
+ indexes
13867
+ ),
13868
+ citationCoverage: indexedScopeCitationRate(providerSlots, ownEdges, indexes)
13869
+ };
13870
+ }),
13871
+ flags: indexes.ambiguousEvidenceKeysByTargetId.get(targetId)?.size ?? 0
13838
13872
  };
13839
- }),
13840
- flags: indexes.ambiguousEvidenceKeysByTargetId.get(targetId)?.size ?? 0
13841
- };
13842
- });
13843
- return {
13844
- eligibleSlots: slots.length,
13845
- answeredSlots: answered2.length,
13846
- includesHistoricalData: prepared.diagnostics.bridgedObservationIds.length > 0 || prepared.diagnostics.historicalObservationIds.length > 0,
13847
- propertiesMentioned: scopePropertiesMentioned(input, targetIds, slots, answered2, prepared),
13848
- mentionCoverage: scopeMentionRate(input, targetIds, slots, answered2, prepared),
13849
- citationCoverage: indexedScopeCitationRate(slots, edges, indexes),
13850
- brandPresence: scopeBrandPresence(input, slots, answered2, prepared),
13851
- namedShareOfVoice: scopeNamedShareOfVoice(input.namedIdentities ?? [], answered2, prepared),
13852
- properties,
13853
- flags: properties.reduce((total, row) => total + row.flags, 0)
13873
+ });
13874
+ return {
13875
+ eligibleSlots: slots.length,
13876
+ answeredSlots: answered2.length,
13877
+ includesHistoricalData: prepared.diagnostics.bridgedObservationIds.length > 0 || prepared.diagnostics.historicalObservationIds.length > 0,
13878
+ propertiesMentioned: scopePropertiesMentioned(input, targetIds, slots, answered2, prepared),
13879
+ mentionCoverage: scopeMentionRate(input, targetIds, slots, answered2, prepared),
13880
+ citationCoverage: indexedScopeCitationRate(slots, edges, indexes),
13881
+ brandPresence: scopeBrandPresence(input, slots, answered2, prepared),
13882
+ namedShareOfVoice: scopeNamedShareOfVoice(namedIdentities ?? [], answered2, prepared),
13883
+ properties,
13884
+ flags: properties.reduce((total, row) => total + row.flags, 0)
13885
+ };
13886
+ }
13854
13887
  };
13855
13888
  }
13889
+ function buildMeasurementOverview(input, options = {}) {
13890
+ return createMeasurementOverviewEvaluator(input, options).evaluate(input.scopeTargetIds, input.namedIdentities);
13891
+ }
13856
13892
  function buildMeasurementEvidence(input, options = {}) {
13857
13893
  const prepared = prepareReport(input, options);
13858
13894
  return { answers: prepared.answers, evidence: prepared.evidence, diagnostics: prepared.diagnostics };
@@ -21408,16 +21444,16 @@ function filteredOverviewWithDb(db, active, plan, run, filters, targetKeys) {
21408
21444
  const location = filters.location === void 0 ? void 0 : normalizeMeasurementLocation(filters.location);
21409
21445
  const expectedSlots = materialized.input.expectedSlots.filter((slot) => (provider === void 0 || normalizeText2(slot.provider) === provider) && (location === void 0 || normalizeMeasurementLocation(slot.location) === location));
21410
21446
  const usageEdges = materialized.input.usageEdges.filter((edge) => filters.queryClass === "all" || materialized.edgeQueryClass.get(edge.id) === filters.queryClass);
21411
- return {
21412
- materialized,
21447
+ const evaluator = createMeasurementOverviewEvaluator({
21448
+ ...materialized.input,
21413
21449
  expectedSlots,
21414
21450
  usageEdges,
21415
- overview: buildMeasurementOverview({
21416
- ...materialized.input,
21417
- expectedSlots,
21418
- usageEdges,
21419
- scopeTargetIds: [...targetKeys]
21420
- })
21451
+ scopeTargetIds: [...targetKeys]
21452
+ });
21453
+ return {
21454
+ materialized,
21455
+ evaluator,
21456
+ overview: evaluator.evaluate(targetKeys)
21421
21457
  };
21422
21458
  }
21423
21459
  function targetAnswers(plan, target, materialized, filters) {
@@ -21537,16 +21573,11 @@ function targetKeysForRun(plan, run, requested, requireEntireScope) {
21537
21573
  }
21538
21574
  return selected;
21539
21575
  }
21540
- function marketRollup(plan, run, materialized, expectedSlots, usageEdges, scopedToOneGroup) {
21576
+ function marketRollup(plan, run, evaluator, scopedToOneGroup) {
21541
21577
  if (scopedToOneGroup) return [];
21542
21578
  return plan.groups.map((group) => {
21543
21579
  const targetKeys = targetKeysForRun(plan, run, group.targetKeys, false);
21544
- const overview = buildMeasurementOverview({
21545
- ...materialized.input,
21546
- expectedSlots,
21547
- usageEdges,
21548
- scopeTargetIds: targetKeys
21549
- });
21580
+ const overview = evaluator.evaluate(targetKeys);
21550
21581
  return {
21551
21582
  groupKey: group.stableKey,
21552
21583
  label: group.label,
@@ -21618,22 +21649,28 @@ function portfolioResponse(db, active, plan, query) {
21618
21649
  truncated: rows2.length > limit2
21619
21650
  });
21620
21651
  }
21621
- const { materialized, overview, expectedSlots: filteredSlots, usageEdges: filteredEdges } = filteredOverviewWithDb(db, active, plan, run, filters, targetKeys);
21652
+ const { materialized, overview, evaluator } = filteredOverviewWithDb(db, active, plan, run, filters, targetKeys);
21622
21653
  const measured = new Map(overview.properties.map((row) => [row.targetId, row]));
21623
- const rows = targets.map((target) => {
21654
+ const ranked = targets.map((target) => {
21624
21655
  const row = measured.get(target.stableKey);
21625
- const recommendations = recommendationRows(target, targetAnswers(plan, target, materialized, filters));
21626
21656
  return {
21657
+ target,
21627
21658
  ...propertyDto2(target),
21628
21659
  mentionCoverage: row ? coverageMetric2(row.mentionCoverage) : unavailable3("no_population"),
21629
21660
  citationCoverage: row ? coverageMetric2(row.citationCoverage) : unavailable3("no_population"),
21630
- flags: row?.flags ?? 0,
21661
+ flags: row?.flags ?? 0
21662
+ };
21663
+ }).sort(compareWeakest);
21664
+ const limit = query.limit ?? DEFAULT_LIMIT3;
21665
+ const rows = ranked.slice(0, limit).map(({ target, ...row }) => {
21666
+ const recommendations = recommendationRows(target, targetAnswers(plan, target, materialized, filters));
21667
+ return {
21668
+ ...row,
21631
21669
  recommendedInstead: recommendations.slice(0, 5).map(({ name, occurrences }) => ({ name, occurrences })),
21632
21670
  recommendedInsteadTotal: recommendations.length,
21633
21671
  recommendedInsteadTruncated: recommendations.length > 5
21634
21672
  };
21635
- }).sort(compareWeakest);
21636
- const limit = query.limit ?? DEFAULT_LIMIT3;
21673
+ });
21637
21674
  return measurementPortfolioSummaryResponseSchema.parse({
21638
21675
  portfolio: {
21639
21676
  groupKey: group?.stableKey ?? null,
@@ -21648,9 +21685,9 @@ function portfolioResponse(db, active, plan, query) {
21648
21685
  citationCoverage: coverageMetric2(overview.citationCoverage)
21649
21686
  },
21650
21687
  weakestProperties: rows.slice(0, limit),
21651
- markets: marketRollup(plan, run, materialized, filteredSlots, filteredEdges, group !== void 0),
21652
- totalProperties: rows.length,
21653
- truncated: rows.length > limit
21688
+ markets: marketRollup(plan, run, evaluator, group !== void 0),
21689
+ totalProperties: ranked.length,
21690
+ truncated: ranked.length > limit
21654
21691
  });
21655
21692
  }
21656
21693
  function propertyCompetitorsResponse(db, active, plan, query) {
@@ -34521,6 +34558,8 @@ var SCHEMA_TABLE = {
34521
34558
  DiscoveryPromoteResult: discoveryPromoteResultSchema,
34522
34559
  DiscoverySessionDetailDto: discoverySessionDetailDtoSchema,
34523
34560
  DiscoverySessionDto: discoverySessionDtoSchema,
34561
+ ResearchBatchCreate: researchBatchCreateSchema,
34562
+ ResearchBatchDto: researchBatchSchema,
34524
34563
  ResearchRunCreate: researchRunCreateSchema,
34525
34564
  ResearchRunDetailDto: researchRunDetailSchema,
34526
34565
  ResearchRunListDto: researchRunListSchema,
@@ -36222,6 +36261,16 @@ var routeCatalog = [
36222
36261
  requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchRunCreate" } } } },
36223
36262
  responses: { 200: jsonResponse("Idempotent request returned its existing research run.", "ResearchRunDetailDto"), 202: jsonResponse("Research batch queued.", "ResearchRunDetailDto"), 400: errorResponse("Invalid provider, model, location, or request."), 403: errorResponse("Paid research was not granted to this principal."), 404: errorResponse("Project not found."), 409: errorResponse("Idempotency key was reused with a different payload."), 422: errorResponse("Research executor is unavailable on this deployment."), 429: errorResponse("The viewer reached the per-project UTC-day research limit.") }
36224
36263
  },
36264
+ {
36265
+ method: "post",
36266
+ path: "/api/v1/projects/{name}/research/batches",
36267
+ summary: "Start bounded explicit research destinations",
36268
+ description: "Queues one to twenty saved research runs atomically, with no more than fifty exact editor questions in total. Every destination explicitly pins its configured API provider, model, and configured location (or null); a scoped destination also pins the published plan revision. The root idempotency key covers the complete ordered destination list: a retry returns the same saved runs even if plans, templates, or provider defaults later change. A changed, reordered, added, or removed destination conflicts. Research never creates tracked queries, shared runs, snapshots, insights, or notifications.",
36269
+ tags: ["research"],
36270
+ parameters: [nameParameter],
36271
+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchBatchCreate" } } } },
36272
+ responses: { 200: jsonResponse("Idempotent request returned its existing saved runs.", "ResearchBatchDto"), 202: jsonResponse("All research destinations were queued.", "ResearchBatchDto"), 400: errorResponse("Each destination must explicitly supply a configured provider, model, location or null, and expected plan revision when scoped. The request permits at most 20 destinations and 50 total concrete queries."), 403: errorResponse("Paid research was not granted to this principal."), 404: errorResponse("Project not found."), 409: errorResponse("The root idempotency key was reused with different ordered destinations."), 422: errorResponse("Research execution is not available on this deployment."), 429: errorResponse("The viewer reached the per-project UTC-day research limit.") }
36273
+ },
36225
36274
  {
36226
36275
  method: "get",
36227
36276
  path: "/api/v1/projects/{name}/query-tracking",
@@ -41970,7 +42019,7 @@ async function keysRoutes(app) {
41970
42019
  if (!row) {
41971
42020
  throw notFound("API key", id);
41972
42021
  }
41973
- return toApiKeyDto(row, projectNameById(app.db));
42022
+ return toApiKeyDto({ ...row, scopes: request.apiKey?.scopes ?? row.scopes }, projectNameById(app.db));
41974
42023
  });
41975
42024
  app.post("/keys", async (request) => {
41976
42025
  requireAdminSession(request);
@@ -83162,13 +83211,14 @@ async function technicalAeoRoutes(app, opts) {
83162
83211
  import crypto49 from "crypto";
83163
83212
  import { and as and55, count as count3, desc as desc28, eq as eq65, gte as gte17, lt as lt11, sql as sql25 } from "drizzle-orm";
83164
83213
  var sameLocation = (a, b) => a.label === b.label && a.city === b.city && a.region === b.region && a.country === b.country && a.timezone === b.timezone;
83214
+ var BATCH_RECEIPT_PREFIX = "__canonry_research_batch__:";
83165
83215
  async function researchRoutes(app, opts) {
83166
83216
  const viewerDailyRunLimit = opts.viewerDailyRunLimit ?? DEFAULT_VIEWER_RESEARCH_DAILY_RUN_LIMIT;
83167
83217
  if (!Number.isInteger(viewerDailyRunLimit) || viewerDailyRunLimit <= 0) {
83168
83218
  throw new Error("viewerDailyRunLimit must be a positive integer");
83169
83219
  }
83170
83220
  app.post("/projects/:name/research/runs", {
83171
- config: { paidRead: true }
83221
+ config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
83172
83222
  }, async (request, reply) => {
83173
83223
  requireResearchGrant(request, opts.allowViewers ?? false);
83174
83224
  const project = resolveProject(app.db, request.params.name);
@@ -83176,6 +83226,7 @@ async function researchRoutes(app, opts) {
83176
83226
  const parsed = researchRunCreateSchema.safeParse(request.body ?? {});
83177
83227
  if (!parsed.success) throw validationError("Invalid research run request", { issues: parsed.error.issues });
83178
83228
  const input = parsed.data;
83229
+ if (input.idempotencyKey?.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
83179
83230
  const adapters = opts.providerAdapters ?? [];
83180
83231
  const configured = new Set(opts.configuredProviderNames ?? []);
83181
83232
  const providerName = input.provider ?? project.providers.find((name) => configured.has(name) && adapters.some((adapter2) => adapter2.name === name && adapter2.mode === "api")) ?? adapters.find((adapter2) => adapter2.mode === "api" && configured.has(adapter2.name))?.name;
@@ -83188,9 +83239,9 @@ async function researchRoutes(app, opts) {
83188
83239
  const existingReceipt = input.idempotencyKey ? app.db.select().from(researchRuns).where(and55(eq65(researchRuns.projectId, project.id), eq65(researchRuns.idempotencyKey, input.idempotencyKey))).get() : void 0;
83189
83240
  const active = activeMeasurementPlan(app.db, project.id);
83190
83241
  const scope = input.scope ? existingReceipt ? resolveStoredResearchScope(existingReceipt.scope, input.scope, input.idempotencyKey) : resolveResearchScope(active, input.scope) : null;
83191
- const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope) : null;
83192
83242
  const location = input.location === void 0 ? scope ? null : project.defaultLocation ? project.locations.find((item2) => item2.label === project.defaultLocation) ?? null : null : input.location;
83193
83243
  if (location && !project.locations.some((item2) => sameLocation(item2, location))) throw validationError("Research location must exactly match a configured project location.", { location });
83244
+ const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
83194
83245
  const requestedModel2 = input.model ?? null;
83195
83246
  const resolvedModel = requestedModel2 ?? (project.providerModels[providerName] || opts.getEffectiveProviderModels?.()[providerName] || adapter.defaultModel);
83196
83247
  adapter.modelValidationPattern.lastIndex = 0;
@@ -83209,13 +83260,13 @@ async function researchRoutes(app, opts) {
83209
83260
  return { reused: true, id: existing.id, shouldDispatch: existing.status === ResearchRunStatuses.queued };
83210
83261
  }
83211
83262
  }
83212
- if (initiatedBy?.kind === "user" && initiatedBy.role === UserRoles.viewer) {
83263
+ if (initiatedBy?.limited) {
83213
83264
  const { start, end, date } = utcDayBounds(now);
83214
83265
  const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
83215
83266
  eq65(researchRuns.projectId, project.id),
83216
83267
  gte17(researchRuns.createdAt, start),
83217
83268
  lt11(researchRuns.createdAt, end),
83218
- sql25`json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer}`
83269
+ sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
83219
83270
  )).get()?.value ?? 0;
83220
83271
  if (used >= viewerDailyRunLimit) {
83221
83272
  throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
@@ -83232,6 +83283,90 @@ async function researchRoutes(app, opts) {
83232
83283
  if (decision.reused) return reply.status(200).send(result);
83233
83284
  return reply.status(202).send(result);
83234
83285
  });
83286
+ app.post("/projects/:name/research/batches", {
83287
+ config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
83288
+ }, async (request, reply) => {
83289
+ requireResearchGrant(request, opts.allowViewers ?? false);
83290
+ const project = resolveProject(app.db, request.params.name);
83291
+ const parsed = researchBatchCreateSchema.safeParse(request.body ?? {});
83292
+ if (!parsed.success) throw validationError("Invalid research batch request", { issues: parsed.error.issues });
83293
+ const input = parsed.data;
83294
+ if (input.idempotencyKey.startsWith(BATCH_RECEIPT_PREFIX)) throw validationError("This idempotency key prefix is reserved for internal research batch receipts.");
83295
+ const requestHash2 = researchBatchRequestHash(input);
83296
+ const receiptKeys = input.runs.map((_run, index2) => batchChildReceiptKey(input.idempotencyKey, index2));
83297
+ const firstReceipt = app.db.select().from(researchRuns).where(and55(
83298
+ eq65(researchRuns.projectId, project.id),
83299
+ eq65(researchRuns.idempotencyKey, receiptKeys[0])
83300
+ )).get();
83301
+ if (firstReceipt) {
83302
+ if (firstReceipt.requestHash !== requestHash2) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
83303
+ const saved = receiptKeys.map((key) => app.db.select().from(researchRuns).where(and55(
83304
+ eq65(researchRuns.projectId, project.id),
83305
+ eq65(researchRuns.idempotencyKey, key)
83306
+ )).get());
83307
+ if (saved.some((row) => !row || row.requestHash !== requestHash2)) throw alreadyExists("Research batch idempotency key", input.idempotencyKey);
83308
+ const runs3 = saved.map((row) => getDetail(app, project.id, row.id));
83309
+ for (const run of runs3) if (run.status === ResearchRunStatuses.queued) opts.onResearchRunRequested?.(run.id, project.id);
83310
+ return reply.status(200).send({ runs: runs3 });
83311
+ }
83312
+ if (!opts.onResearchRunRequested) throw missingDependency("Research execution is not available on this deployment.", { reason: "no-research-handler" });
83313
+ const active = activeMeasurementPlan(app.db, project.id);
83314
+ const prepared = input.runs.map((run) => prepareBatchRun(app, opts, project, active, run));
83315
+ const now = (/* @__PURE__ */ new Date()).toISOString();
83316
+ const initiatedBy = researchPrincipal(request);
83317
+ const decision = app.db.transaction((tx) => {
83318
+ if (initiatedBy?.limited) {
83319
+ const { start, end, date } = utcDayBounds(now);
83320
+ const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
83321
+ eq65(researchRuns.projectId, project.id),
83322
+ gte17(researchRuns.createdAt, start),
83323
+ lt11(researchRuns.createdAt, end),
83324
+ sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
83325
+ )).get()?.value ?? 0;
83326
+ if (used + prepared.length > viewerDailyRunLimit) throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
83327
+ }
83328
+ const ids = [];
83329
+ for (const [index2, run] of prepared.entries()) {
83330
+ const id = crypto49.randomUUID();
83331
+ ids.push(id);
83332
+ tx.insert(researchRuns).values({
83333
+ id,
83334
+ projectId: project.id,
83335
+ status: ResearchRunStatuses.queued,
83336
+ provider: run.providerName,
83337
+ requestedModel: run.requestedModel,
83338
+ resolvedModel: run.resolvedModel,
83339
+ location: run.location,
83340
+ totalQueries: run.input.queries.length,
83341
+ scope: run.scope,
83342
+ template: run.template,
83343
+ idempotencyKey: receiptKeys[index2],
83344
+ requestHash: requestHash2,
83345
+ initiatedBy,
83346
+ createdAt: now
83347
+ }).run();
83348
+ for (const [position, query] of run.input.queries.entries()) tx.insert(researchRunQueries).values({
83349
+ id: crypto49.randomUUID(),
83350
+ researchRunId: id,
83351
+ position,
83352
+ queryText: query,
83353
+ queryClass: run.queryClasses[position] ?? null,
83354
+ status: ResearchQueryStatuses.queued,
83355
+ requestedModel: run.requestedModel,
83356
+ resolvedModel: run.resolvedModel,
83357
+ groundingSources: [],
83358
+ citedDomains: [],
83359
+ searchQueries: [],
83360
+ createdAt: now
83361
+ }).run();
83362
+ writeAuditLog(tx, { projectId: project.id, actor: initiatedBy ? `${initiatedBy.kind}:${initiatedBy.id}` : "api", action: "research.created", entityType: "research_run", entityId: id });
83363
+ }
83364
+ return { ids };
83365
+ });
83366
+ const runs2 = decision.ids.map((id) => getDetail(app, project.id, id));
83367
+ for (const id of decision.ids) opts.onResearchRunRequested(id, project.id);
83368
+ return reply.status(202).send({ runs: runs2 });
83369
+ });
83235
83370
  app.get("/projects/:name/research/runs", async (request) => {
83236
83371
  const project = resolveProject(app.db, request.params.name);
83237
83372
  const requested = Number.parseInt(request.query.limit ?? "", 10);
@@ -83239,10 +83374,10 @@ async function researchRoutes(app, opts) {
83239
83374
  const runs2 = app.db.select().from(researchRuns).where(eq65(researchRuns.projectId, project.id)).orderBy(desc28(researchRuns.createdAt)).limit(limit).all().map(serializeRun);
83240
83375
  const configured = new Set(opts.configuredProviderNames ?? []);
83241
83376
  const effectiveModels2 = opts.getEffectiveProviderModels?.() ?? {};
83242
- const providers = await Promise.all((opts.providerAdapters ?? []).filter((adapter) => adapter.mode === "api" && !isBrowserProvider(adapter.name) && configured.has(adapter.name)).map(async (adapter) => {
83377
+ const providers = (opts.providerAdapters ?? []).filter((adapter) => adapter.mode === "api" && !isBrowserProvider(adapter.name) && configured.has(adapter.name)).map((adapter) => {
83243
83378
  const defaultModel = project.providerModels[adapter.name] || effectiveModels2[adapter.name] || adapter.defaultModel;
83244
- const discovered = opts.getProviderModels ? await opts.getProviderModels(adapter.name) : [];
83245
- const models = discovered.length ? discovered : adapter.knownModels;
83379
+ const cached = opts.getCachedProviderModels?.(adapter.name) ?? [];
83380
+ const models = cached.length ? cached : adapter.knownModels;
83246
83381
  return {
83247
83382
  name: adapter.name,
83248
83383
  displayName: adapter.displayName,
@@ -83250,8 +83385,10 @@ async function researchRoutes(app, opts) {
83250
83385
  defaultModel,
83251
83386
  knownModels: [...new Map([{ id: defaultModel, displayName: defaultModel }, ...models].map((model) => [model.id, { id: model.id, displayName: model.displayName }])).values()]
83252
83387
  };
83253
- }));
83254
- return { runs: runs2, providers };
83388
+ });
83389
+ const canRun = canRunResearch(request, opts.allowViewers ?? false);
83390
+ const limited = request.principal !== void 0 && !request.principal.scopes.includes(WILDCARD_SCOPE);
83391
+ return { runs: runs2, providers, access: { canRun, dailyRunLimit: canRun && limited ? viewerDailyRunLimit : null } };
83255
83392
  });
83256
83393
  app.get("/projects/:name/research/runs/:runId", async (request) => {
83257
83394
  const project = resolveProject(app.db, request.params.name);
@@ -83264,6 +83401,44 @@ function resolveStoredResearchScope(stored, selection, idempotencyKey) {
83264
83401
  }
83265
83402
  return stored;
83266
83403
  }
83404
+ function batchChildReceiptKey(rootKey, index2) {
83405
+ const derived = crypto49.createHash("sha256").update(`${rootKey}\0${index2}`).digest("hex");
83406
+ return `${BATCH_RECEIPT_PREFIX}${derived}:${index2}`;
83407
+ }
83408
+ function researchBatchRequestHash(input) {
83409
+ return crypto49.createHash("sha256").update(JSON.stringify({ runs: input.runs })).digest("hex");
83410
+ }
83411
+ function prepareBatchRun(app, opts, project, active, input) {
83412
+ const adapters = opts.providerAdapters ?? [];
83413
+ const configured = new Set(opts.configuredProviderNames ?? []);
83414
+ const providerName = input.provider;
83415
+ const adapter = adapters.find((candidate) => candidate.name === providerName);
83416
+ if (!adapter || adapter.mode !== "api" || isBrowserProvider(providerName) || !configured.has(providerName)) {
83417
+ throw validationError("Research requires a configured API provider.", { provider: providerName, validProviders: adapters.filter((a) => a.mode === "api" && configured.has(a.name)).map((a) => a.name) });
83418
+ }
83419
+ adapter.modelValidationPattern.lastIndex = 0;
83420
+ if (!adapter.modelValidationPattern.test(input.model) || !adapter.modelConfigurable && input.model !== adapter.defaultModel) {
83421
+ throw validationError(`Invalid model "${input.model}" for provider "${providerName}".`, { provider: providerName, model: input.model, hint: adapter.modelValidationHint });
83422
+ }
83423
+ const location = input.location;
83424
+ if (location && !project.locations.some((item2) => sameLocation(item2, location))) {
83425
+ throw validationError("Research location must exactly match a configured project location.", { location });
83426
+ }
83427
+ if (deduplicateResearchQueries(input.queries).length !== input.queries.length) throw validationError("Research queries must be unique within each batch destination.");
83428
+ const scope = input.scope ? resolveResearchScope(active, input.scope) : null;
83429
+ const template = input.template ? resolveResearchTemplate(app, project.id, input.template, scope, location) : null;
83430
+ return {
83431
+ input,
83432
+ providerName,
83433
+ requestedModel: input.model,
83434
+ // The batch contract requires the model so retries never consult a project or instance default.
83435
+ resolvedModel: input.model,
83436
+ location,
83437
+ scope,
83438
+ template,
83439
+ queryClasses: classifyResearchQueries(project, active?.plan ?? null, input.queries)
83440
+ };
83441
+ }
83267
83442
  function resolveResearchScope(active, selection) {
83268
83443
  if (!active || active.plan.schemaVersion !== 2) {
83269
83444
  throw validationError("Research scope requires an active Advanced Measurement plan.");
@@ -83284,8 +83459,7 @@ function resolveStoredResearchTemplate(stored, selection, idempotencyKey) {
83284
83459
  }
83285
83460
  return stored;
83286
83461
  }
83287
- function resolveResearchTemplate(app, projectId, selection, scope) {
83288
- if (!scope) throw validationError("Research templates require a selected market or property scope.");
83462
+ function resolveResearchTemplate(app, projectId, selection, scope, location) {
83289
83463
  const savedTemplate = app.db.select().from(measurementQueryTemplates).where(and55(
83290
83464
  eq65(measurementQueryTemplates.projectId, projectId),
83291
83465
  eq65(measurementQueryTemplates.id, selection.templateId)
@@ -83295,7 +83469,7 @@ function resolveResearchTemplate(app, projectId, selection, scope) {
83295
83469
  if (templateVersion !== selection.templateVersion) {
83296
83470
  throw validationError("The selected research template is stale. Reload it and try again.");
83297
83471
  }
83298
- const { bindings, output } = expandResearchTemplate(savedTemplate, scope);
83472
+ const { bindings, output } = expandResearchTemplate(savedTemplate, scope, location);
83299
83473
  if (!output || output.length > 4e3) throw validationError("Research template output must be between 1 and 4000 characters.");
83300
83474
  return { templateId: selection.templateId, templateVersion, template: savedTemplate.pattern, bindings, output };
83301
83475
  }
@@ -83319,11 +83493,13 @@ function serializeQuery(row) {
83319
83493
  function researchPrincipal(request) {
83320
83494
  const principal = request.principal;
83321
83495
  if (!principal) return null;
83496
+ const actor = principal.delegatedUser ? { ...principal.delegatedUser, kind: "user" } : principal;
83322
83497
  return {
83323
- kind: principal.kind,
83324
- id: principal.id,
83325
- name: principal.name,
83326
- role: principal.kind === "user" ? principal.role ?? null : null
83498
+ kind: actor.kind,
83499
+ id: actor.id,
83500
+ name: actor.name,
83501
+ role: actor.kind === "user" ? actor.role ?? null : null,
83502
+ ...!principal.scopes.includes(WILDCARD_SCOPE) ? { limited: true } : {}
83327
83503
  };
83328
83504
  }
83329
83505
  function utcDayBounds(now) {
@@ -83447,6 +83623,7 @@ async function apiRoutes(app, opts) {
83447
83623
  });
83448
83624
  if (!opts.skipAuth) {
83449
83625
  await authPlugin(api, {
83626
+ researchAllowViewers: opts.researchAllowViewers,
83450
83627
  // A bearer that is not an api key is tried as an OAuth access token.
83451
83628
  // Wired unconditionally: the table is empty until an operator registers
83452
83629
  // a client, so an install with no OAuth in use pays one indexed miss.
@@ -83663,7 +83840,7 @@ async function apiRoutes(app, opts) {
83663
83840
  embedQueries: opts.embedQueries
83664
83841
  });
83665
83842
  await api.register(researchRoutes, {
83666
- getProviderModels: opts.getProviderModels,
83843
+ getCachedProviderModels: opts.getCachedProviderModels,
83667
83844
  getEffectiveProviderModels: opts.getEffectiveProviderModels,
83668
83845
  providerAdapters: opts.providerAdapters,
83669
83846
  configuredProviderNames: opts.providerSummary?.filter((provider) => provider.configured).map((provider) => provider.name),