@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.
- package/assets/agent-workspace/skills/canonry/SKILL.md +124 -216
- package/assets/assets/{AuditHistoryPanel-Dob_85ay.js → AuditHistoryPanel-C2dvjYNh.js} +1 -1
- package/assets/assets/{BacklinksPage-BDm4gQsa.js → BacklinksPage-D1yPQtfF.js} +1 -1
- package/assets/assets/{HistoryPage-BUGJKNWJ.js → HistoryPage-DOEa7naA.js} +1 -1
- package/assets/assets/{MeasurementPropertyPage-H2g4VdcA.js → MeasurementPropertyPage-D-O8vnMS.js} +1 -1
- package/assets/assets/ProjectPage-DqTCcimz.js +8 -0
- package/assets/assets/{RunRow-Cja3vahD.js → RunRow-BO7lyC1C.js} +1 -1
- package/assets/assets/{RunsPage-BdVfLW9Z.js → RunsPage-DHSWfTav.js} +1 -1
- package/assets/assets/{SettingsPage-Xw9rdDPd.js → SettingsPage-oiJdhiq0.js} +1 -1
- package/assets/assets/SiteHealthSection-gVdylFY-.js +4 -0
- package/assets/assets/{TrafficPage-D5ZqJrJm.js → TrafficPage-YIdwRpQh.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-DB5V8cdH.js → TrafficSourceDetailPage-B4Cop6ox.js} +1 -1
- package/assets/assets/{extract-error-message-lzuarNEg.js → extract-error-message-XavUZNf3.js} +1 -1
- package/assets/assets/{index-BjjM151x.css → index-BtTEcbcB.css} +1 -1
- package/assets/assets/{index-YVHujfy1.js → index-njfYnO22.js} +25 -25
- package/assets/assets/{react-sigma_core.esm.min-Ddu-c40B.js → react-sigma_core.esm.min-V7giUpEn.js} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-32QBVRMN.js → chunk-2A7E5TDU.js} +209 -29
- package/dist/{chunk-ROPJUNQN.js → chunk-KBUV3HGS.js} +55 -9
- package/dist/{chunk-2II4ZMMH.js → chunk-WSD5J6O4.js} +2 -2
- package/dist/{chunk-BE366UCM.js → chunk-XI6FN52S.js} +33 -13
- package/dist/{chunk-ECPN4OUR.js → chunk-ZK4FJDCH.js} +217 -48
- package/dist/cli.js +56 -5
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-QCKX2W4G.js → intelligence-service-EVRWZHDG.js} +2 -2
- package/dist/mcp.js +13 -8
- package/package.json +9 -9
- package/assets/assets/ProjectPage-d7iOSkrd.js +0 -8
- 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-
|
|
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
|
-
|
|
10833
|
-
if (
|
|
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 (
|
|
10848
|
-
|
|
10849
|
-
throw forbidden("
|
|
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
|
-
|
|
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
|
-
|
|
11013
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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 =
|
|
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
|
|
83253
|
-
const models =
|
|
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
|
-
|
|
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:
|
|
83332
|
-
id:
|
|
83333
|
-
name:
|
|
83334
|
-
role:
|
|
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
|
-
|
|
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),
|