@ainyc/canonry 4.190.1 → 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 (29) hide show
  1. package/assets/agent-workspace/skills/canonry/SKILL.md +124 -216
  2. package/assets/assets/{AuditHistoryPanel-Dob_85ay.js → AuditHistoryPanel-C2dvjYNh.js} +1 -1
  3. package/assets/assets/{BacklinksPage-BDm4gQsa.js → BacklinksPage-D1yPQtfF.js} +1 -1
  4. package/assets/assets/{HistoryPage-BUGJKNWJ.js → HistoryPage-DOEa7naA.js} +1 -1
  5. package/assets/assets/{MeasurementPropertyPage-H2g4VdcA.js → MeasurementPropertyPage-D-O8vnMS.js} +1 -1
  6. package/assets/assets/ProjectPage-DqTCcimz.js +8 -0
  7. package/assets/assets/{RunRow-Cja3vahD.js → RunRow-BO7lyC1C.js} +1 -1
  8. package/assets/assets/{RunsPage-BdVfLW9Z.js → RunsPage-DHSWfTav.js} +1 -1
  9. package/assets/assets/{SettingsPage-Xw9rdDPd.js → SettingsPage-oiJdhiq0.js} +1 -1
  10. package/assets/assets/SiteHealthSection-gVdylFY-.js +4 -0
  11. package/assets/assets/{TrafficPage-D5ZqJrJm.js → TrafficPage-YIdwRpQh.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-DB5V8cdH.js → TrafficSourceDetailPage-B4Cop6ox.js} +1 -1
  13. package/assets/assets/{extract-error-message-lzuarNEg.js → extract-error-message-XavUZNf3.js} +1 -1
  14. package/assets/assets/{index-BjjM151x.css → index-BtTEcbcB.css} +1 -1
  15. package/assets/assets/{index-YVHujfy1.js → index-njfYnO22.js} +25 -25
  16. package/assets/assets/{react-sigma_core.esm.min-Ddu-c40B.js → react-sigma_core.esm.min-V7giUpEn.js} +1 -1
  17. package/assets/index.html +2 -2
  18. package/dist/{chunk-32QBVRMN.js → chunk-2A7E5TDU.js} +209 -29
  19. package/dist/{chunk-ROPJUNQN.js → chunk-KBUV3HGS.js} +55 -9
  20. package/dist/{chunk-2II4ZMMH.js → chunk-WSD5J6O4.js} +2 -2
  21. package/dist/{chunk-BE366UCM.js → chunk-XI6FN52S.js} +33 -13
  22. package/dist/{chunk-ECPN4OUR.js → chunk-ZK4FJDCH.js} +217 -48
  23. package/dist/cli.js +56 -5
  24. package/dist/index.js +4 -4
  25. package/dist/{intelligence-service-QCKX2W4G.js → intelligence-service-EVRWZHDG.js} +2 -2
  26. package/dist/mcp.js +13 -8
  27. package/package.json +9 -9
  28. package/assets/assets/ProjectPage-d7iOSkrd.js +0 -8
  29. package/assets/assets/SiteHealthSection-CjioXMWy.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>
@@ -34529,6 +34558,8 @@ var SCHEMA_TABLE = {
34529
34558
  DiscoveryPromoteResult: discoveryPromoteResultSchema,
34530
34559
  DiscoverySessionDetailDto: discoverySessionDetailDtoSchema,
34531
34560
  DiscoverySessionDto: discoverySessionDtoSchema,
34561
+ ResearchBatchCreate: researchBatchCreateSchema,
34562
+ ResearchBatchDto: researchBatchSchema,
34532
34563
  ResearchRunCreate: researchRunCreateSchema,
34533
34564
  ResearchRunDetailDto: researchRunDetailSchema,
34534
34565
  ResearchRunListDto: researchRunListSchema,
@@ -36230,6 +36261,16 @@ var routeCatalog = [
36230
36261
  requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ResearchRunCreate" } } } },
36231
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.") }
36232
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
+ },
36233
36274
  {
36234
36275
  method: "get",
36235
36276
  path: "/api/v1/projects/{name}/query-tracking",
@@ -41978,7 +42019,7 @@ async function keysRoutes(app) {
41978
42019
  if (!row) {
41979
42020
  throw notFound("API key", id);
41980
42021
  }
41981
- return toApiKeyDto(row, projectNameById(app.db));
42022
+ return toApiKeyDto({ ...row, scopes: request.apiKey?.scopes ?? row.scopes }, projectNameById(app.db));
41982
42023
  });
41983
42024
  app.post("/keys", async (request) => {
41984
42025
  requireAdminSession(request);
@@ -83170,13 +83211,14 @@ async function technicalAeoRoutes(app, opts) {
83170
83211
  import crypto49 from "crypto";
83171
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";
83172
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__:";
83173
83215
  async function researchRoutes(app, opts) {
83174
83216
  const viewerDailyRunLimit = opts.viewerDailyRunLimit ?? DEFAULT_VIEWER_RESEARCH_DAILY_RUN_LIMIT;
83175
83217
  if (!Number.isInteger(viewerDailyRunLimit) || viewerDailyRunLimit <= 0) {
83176
83218
  throw new Error("viewerDailyRunLimit must be a positive integer");
83177
83219
  }
83178
83220
  app.post("/projects/:name/research/runs", {
83179
- config: { paidRead: true }
83221
+ config: { paidRead: true, writeScope: RESEARCH_RUN_SCOPE }
83180
83222
  }, async (request, reply) => {
83181
83223
  requireResearchGrant(request, opts.allowViewers ?? false);
83182
83224
  const project = resolveProject(app.db, request.params.name);
@@ -83184,6 +83226,7 @@ async function researchRoutes(app, opts) {
83184
83226
  const parsed = researchRunCreateSchema.safeParse(request.body ?? {});
83185
83227
  if (!parsed.success) throw validationError("Invalid research run request", { issues: parsed.error.issues });
83186
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.");
83187
83230
  const adapters = opts.providerAdapters ?? [];
83188
83231
  const configured = new Set(opts.configuredProviderNames ?? []);
83189
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;
@@ -83196,9 +83239,9 @@ async function researchRoutes(app, opts) {
83196
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;
83197
83240
  const active = activeMeasurementPlan(app.db, project.id);
83198
83241
  const scope = input.scope ? existingReceipt ? resolveStoredResearchScope(existingReceipt.scope, input.scope, input.idempotencyKey) : resolveResearchScope(active, input.scope) : null;
83199
- const template = input.template ? existingReceipt ? resolveStoredResearchTemplate(existingReceipt.template, input.template, input.idempotencyKey) : resolveResearchTemplate(app, project.id, input.template, scope) : null;
83200
83242
  const location = input.location === void 0 ? scope ? null : project.defaultLocation ? project.locations.find((item2) => item2.label === project.defaultLocation) ?? null : null : input.location;
83201
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;
83202
83245
  const requestedModel2 = input.model ?? null;
83203
83246
  const resolvedModel = requestedModel2 ?? (project.providerModels[providerName] || opts.getEffectiveProviderModels?.()[providerName] || adapter.defaultModel);
83204
83247
  adapter.modelValidationPattern.lastIndex = 0;
@@ -83217,13 +83260,13 @@ async function researchRoutes(app, opts) {
83217
83260
  return { reused: true, id: existing.id, shouldDispatch: existing.status === ResearchRunStatuses.queued };
83218
83261
  }
83219
83262
  }
83220
- if (initiatedBy?.kind === "user" && initiatedBy.role === UserRoles.viewer) {
83263
+ if (initiatedBy?.limited) {
83221
83264
  const { start, end, date } = utcDayBounds(now);
83222
83265
  const used = tx.select({ value: count3() }).from(researchRuns).where(and55(
83223
83266
  eq65(researchRuns.projectId, project.id),
83224
83267
  gte17(researchRuns.createdAt, start),
83225
83268
  lt11(researchRuns.createdAt, end),
83226
- sql25`json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer}`
83269
+ sql25`(json_extract(${researchRuns.initiatedBy}, '$.role') = ${UserRoles.viewer} OR json_extract(${researchRuns.initiatedBy}, '$.limited') = 1)`
83227
83270
  )).get()?.value ?? 0;
83228
83271
  if (used >= viewerDailyRunLimit) {
83229
83272
  throw researchDailyLimitExceeded(project.name, viewerDailyRunLimit, date);
@@ -83240,6 +83283,90 @@ async function researchRoutes(app, opts) {
83240
83283
  if (decision.reused) return reply.status(200).send(result);
83241
83284
  return reply.status(202).send(result);
83242
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
+ });
83243
83370
  app.get("/projects/:name/research/runs", async (request) => {
83244
83371
  const project = resolveProject(app.db, request.params.name);
83245
83372
  const requested = Number.parseInt(request.query.limit ?? "", 10);
@@ -83247,10 +83374,10 @@ async function researchRoutes(app, opts) {
83247
83374
  const runs2 = app.db.select().from(researchRuns).where(eq65(researchRuns.projectId, project.id)).orderBy(desc28(researchRuns.createdAt)).limit(limit).all().map(serializeRun);
83248
83375
  const configured = new Set(opts.configuredProviderNames ?? []);
83249
83376
  const effectiveModels2 = opts.getEffectiveProviderModels?.() ?? {};
83250
- 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) => {
83251
83378
  const defaultModel = project.providerModels[adapter.name] || effectiveModels2[adapter.name] || adapter.defaultModel;
83252
- const discovered = opts.getProviderModels ? await opts.getProviderModels(adapter.name) : [];
83253
- const models = discovered.length ? discovered : adapter.knownModels;
83379
+ const cached = opts.getCachedProviderModels?.(adapter.name) ?? [];
83380
+ const models = cached.length ? cached : adapter.knownModels;
83254
83381
  return {
83255
83382
  name: adapter.name,
83256
83383
  displayName: adapter.displayName,
@@ -83258,8 +83385,10 @@ async function researchRoutes(app, opts) {
83258
83385
  defaultModel,
83259
83386
  knownModels: [...new Map([{ id: defaultModel, displayName: defaultModel }, ...models].map((model) => [model.id, { id: model.id, displayName: model.displayName }])).values()]
83260
83387
  };
83261
- }));
83262
- 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 } };
83263
83392
  });
83264
83393
  app.get("/projects/:name/research/runs/:runId", async (request) => {
83265
83394
  const project = resolveProject(app.db, request.params.name);
@@ -83272,6 +83401,44 @@ function resolveStoredResearchScope(stored, selection, idempotencyKey) {
83272
83401
  }
83273
83402
  return stored;
83274
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
+ }
83275
83442
  function resolveResearchScope(active, selection) {
83276
83443
  if (!active || active.plan.schemaVersion !== 2) {
83277
83444
  throw validationError("Research scope requires an active Advanced Measurement plan.");
@@ -83292,8 +83459,7 @@ function resolveStoredResearchTemplate(stored, selection, idempotencyKey) {
83292
83459
  }
83293
83460
  return stored;
83294
83461
  }
83295
- function resolveResearchTemplate(app, projectId, selection, scope) {
83296
- if (!scope) throw validationError("Research templates require a selected market or property scope.");
83462
+ function resolveResearchTemplate(app, projectId, selection, scope, location) {
83297
83463
  const savedTemplate = app.db.select().from(measurementQueryTemplates).where(and55(
83298
83464
  eq65(measurementQueryTemplates.projectId, projectId),
83299
83465
  eq65(measurementQueryTemplates.id, selection.templateId)
@@ -83303,7 +83469,7 @@ function resolveResearchTemplate(app, projectId, selection, scope) {
83303
83469
  if (templateVersion !== selection.templateVersion) {
83304
83470
  throw validationError("The selected research template is stale. Reload it and try again.");
83305
83471
  }
83306
- const { bindings, output } = expandResearchTemplate(savedTemplate, scope);
83472
+ const { bindings, output } = expandResearchTemplate(savedTemplate, scope, location);
83307
83473
  if (!output || output.length > 4e3) throw validationError("Research template output must be between 1 and 4000 characters.");
83308
83474
  return { templateId: selection.templateId, templateVersion, template: savedTemplate.pattern, bindings, output };
83309
83475
  }
@@ -83327,11 +83493,13 @@ function serializeQuery(row) {
83327
83493
  function researchPrincipal(request) {
83328
83494
  const principal = request.principal;
83329
83495
  if (!principal) return null;
83496
+ const actor = principal.delegatedUser ? { ...principal.delegatedUser, kind: "user" } : principal;
83330
83497
  return {
83331
- kind: principal.kind,
83332
- id: principal.id,
83333
- name: principal.name,
83334
- 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 } : {}
83335
83503
  };
83336
83504
  }
83337
83505
  function utcDayBounds(now) {
@@ -83455,6 +83623,7 @@ async function apiRoutes(app, opts) {
83455
83623
  });
83456
83624
  if (!opts.skipAuth) {
83457
83625
  await authPlugin(api, {
83626
+ researchAllowViewers: opts.researchAllowViewers,
83458
83627
  // A bearer that is not an api key is tried as an OAuth access token.
83459
83628
  // Wired unconditionally: the table is empty until an operator registers
83460
83629
  // a client, so an install with no OAuth in use pays one indexed miss.
@@ -83671,7 +83840,7 @@ async function apiRoutes(app, opts) {
83671
83840
  embedQueries: opts.embedQueries
83672
83841
  });
83673
83842
  await api.register(researchRoutes, {
83674
- getProviderModels: opts.getProviderModels,
83843
+ getCachedProviderModels: opts.getCachedProviderModels,
83675
83844
  getEffectiveProviderModels: opts.getEffectiveProviderModels,
83676
83845
  providerAdapters: opts.providerAdapters,
83677
83846
  configuredProviderNames: opts.providerSummary?.filter((provider) => provider.configured).map((provider) => provider.name),