@kindgi/api 0.1.5-rc.0 → 0.1.5
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/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/cost-binding.d.ts +19 -0
- package/dist/cost-binding.d.ts.map +1 -1
- package/dist/cost-binding.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/middleware/authorize.d.ts +5 -2
- package/dist/middleware/authorize.d.ts.map +1 -1
- package/dist/middleware/authorize.js +10 -1
- package/dist/middleware/authorize.js.map +1 -1
- package/dist/openapi/operations.js +1 -1
- package/dist/openapi/operations.js.map +1 -1
- package/dist/openapi/schemas.js +1 -1
- package/dist/openapi/schemas.js.map +1 -1
- package/dist/publish-refused.d.ts +12 -2
- package/dist/publish-refused.d.ts.map +1 -1
- package/dist/publish-refused.js +6 -0
- package/dist/publish-refused.js.map +1 -1
- package/dist/routes/cost.d.ts +8 -3
- package/dist/routes/cost.d.ts.map +1 -1
- package/dist/routes/cost.js +56 -4
- package/dist/routes/cost.js.map +1 -1
- package/dist/routes/deployments.d.ts +8 -0
- package/dist/routes/deployments.d.ts.map +1 -1
- package/dist/routes/deployments.js +127 -1
- package/dist/routes/deployments.js.map +1 -1
- package/dist/routes/eval-runs.js +4 -1
- package/dist/routes/eval-runs.js.map +1 -1
- package/dist/routes/eval-suites.d.ts.map +1 -1
- package/dist/routes/eval-suites.js +8 -1
- package/dist/routes/eval-suites.js.map +1 -1
- package/dist/routes/memory-access.d.ts.map +1 -1
- package/dist/routes/memory-access.js +18 -33
- package/dist/routes/memory-access.js.map +1 -1
- package/dist/routes/readable-projects.d.ts +26 -0
- package/dist/routes/readable-projects.d.ts.map +1 -0
- package/dist/routes/readable-projects.js +39 -0
- package/dist/routes/readable-projects.js.map +1 -0
- package/openapi.json +3 -2
- package/package.json +21 -21
- package/src/app.ts +1 -1
- package/src/cost-binding.ts +19 -0
- package/src/errors.ts +2 -0
- package/src/middleware/authorize.ts +19 -3
- package/src/openapi/operations.ts +1 -1
- package/src/openapi/schemas.ts +1 -1
- package/src/publish-refused.ts +20 -2
- package/src/routes/cost.ts +81 -4
- package/src/routes/deployments.ts +148 -1
- package/src/routes/eval-runs.ts +3 -0
- package/src/routes/eval-suites.ts +7 -1
- package/src/routes/memory-access.ts +19 -38
- package/src/routes/readable-projects.ts +66 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
import { ref } from '@kindgi/authz';
|
|
4
|
+
const PROJECT_PAGE = 200;
|
|
5
|
+
/** The tenant's projects, every page; none without a project binding. */
|
|
6
|
+
export async function allProjects(c, projects) {
|
|
7
|
+
if (projects === undefined)
|
|
8
|
+
return [];
|
|
9
|
+
const tenantId = c.get('tenantId');
|
|
10
|
+
const all = [];
|
|
11
|
+
let cursor;
|
|
12
|
+
for (;;) {
|
|
13
|
+
const page = await projects.list(tenantId, {
|
|
14
|
+
limit: PROJECT_PAGE,
|
|
15
|
+
...(cursor !== undefined && { cursor }),
|
|
16
|
+
});
|
|
17
|
+
all.push(...page.items.map((p) => ({ id: p.id, ...(p.orgId !== undefined && { orgId: p.orgId }) })));
|
|
18
|
+
if (page.nextCursor === undefined)
|
|
19
|
+
break;
|
|
20
|
+
cursor = page.nextCursor;
|
|
21
|
+
}
|
|
22
|
+
return all;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The ids of the projects the caller may `action`: listed by the
|
|
26
|
+
* authorizer, else the tenant's projects checked one by one. `undefined`
|
|
27
|
+
* when neither can say (the authorizer can't list, and there's no project
|
|
28
|
+
* binding to check against).
|
|
29
|
+
*/
|
|
30
|
+
export async function projectIdsCallerMay(c, authorizer, projects, action) {
|
|
31
|
+
const listed = await authorizer.listObjects?.(c, action, 'project');
|
|
32
|
+
if (listed !== undefined)
|
|
33
|
+
return listed.map((id) => id);
|
|
34
|
+
if (projects === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
const may = await authorizer.filterByCan(c, action, await allProjects(c, projects), (p) => ref('project', p.id));
|
|
37
|
+
return may.map((p) => p.id);
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=readable-projects.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readable-projects.js","sourceRoot":"","sources":["../../src/routes/readable-projects.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;AAWjC,OAAO,EAAe,GAAG,EAAE,MAAM,eAAe,CAAC;AAOjD,MAAM,YAAY,GAAG,GAAG,CAAC;AAIzB,yEAAyE;AACzE,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,CAAkB,EAClB,QAAkD;IAElD,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACtC,MAAM,QAAQ,GAAG,CAAC,CAAC,GAAG,CAAC,UAAU,CAAa,CAAC;IAC/C,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,IAAI,MAAuD,CAAC;IAC5D,SAAS,CAAC;QACR,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE;YACzC,KAAK,EAAE,YAAY;YACnB,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,EAAE,CAAC;SACxC,CAAC,CAAC;QACH,GAAG,CAAC,IAAI,CACN,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,KAAK,SAAS,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC,CAC3F,CAAC;QACF,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,MAAM;QACzC,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC;IAC3B,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,CAAkB,EAClB,UAAsB,EACtB,QAAkD,EAClD,MAAc;IAEd,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;IACpE,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAe,CAAC,CAAC;IACrE,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC7C,MAAM,GAAG,GAAG,MAAM,UAAU,CAAC,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CACxF,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,EAAuB,CAAC,CAC1C,CAAC;IACF,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAC9B,CAAC"}
|
package/openapi.json
CHANGED
|
@@ -248,6 +248,7 @@
|
|
|
248
248
|
"tool-already-registered": 409,
|
|
249
249
|
"tool-project-mismatch": 409,
|
|
250
250
|
"guardrail-already-registered": 409,
|
|
251
|
+
"guardrail-project-mismatch": 409,
|
|
251
252
|
"guardrail-config-invalid": 422,
|
|
252
253
|
"flow-already-registered": 409,
|
|
253
254
|
"flow-project-mismatch": 409,
|
|
@@ -4468,7 +4469,7 @@
|
|
|
4468
4469
|
"same-project",
|
|
4469
4470
|
"tenant"
|
|
4470
4471
|
],
|
|
4471
|
-
"description": "What the intent selects within what the run may see. Facts: this conversation's;
|
|
4472
|
+
"description": "What the intent selects within what the run may see. Facts: this conversation's; the run's end user's only (`same-user`: none when the run names no `participantId`); the run's project's (none without a project); or every fact of the type it may see (`tenant`). Conversations: this end user's other conversations with the agent (`same-user`: none when the run names no `participantId`); this conversation's messages older than the history window (`same-conversation`); conversations in the run's segment path (`same-segment`) or its project (`same-project`), whoever had them: those two quote other people's conversations, so publishing warns and their messages are marked as another person's. `same-segment` is for conversations only, `tenant` for facts only."
|
|
4472
4473
|
},
|
|
4473
4474
|
"limit": {
|
|
4474
4475
|
"type": "integer",
|
|
@@ -31574,7 +31575,7 @@
|
|
|
31574
31575
|
}
|
|
31575
31576
|
}
|
|
31576
31577
|
},
|
|
31577
|
-
"description": "Primary consumer path for dashboards. `groupBy` is required (comma-separated dimensions from the closed set); time range is required (both `from` and `to`, or both omitted for the default last-30-days window echoed back in `timeRange`). Filters compose on top of the time window. `?scopeKind + ?scopeId` narrow the aggregate to a scope: `org` covers every project in the org, so one call sums an org's spend. Each group, and the total, carries its cost and its token sums (`tokens`). `groups` is ordered by `totalUsd`, highest first (ties by key), and capped at `limit` (default 1000): `truncated` and `totalGroups` say when there were more, and the totals still cover every record. For every record, page through `/v1/cost/records`. `inherit` has no effect on cost records, which always belong to a project.",
|
|
31578
|
+
"description": "Primary consumer path for dashboards. `groupBy` is required (comma-separated dimensions from the closed set); time range is required (both `from` and `to`, or both omitted for the default last-30-days window echoed back in `timeRange`). Filters compose on top of the time window. `?scopeKind + ?scopeId` narrow the aggregate to a scope: `org` covers every project in the org, so one call sums an org's spend. It needs `read` on the scope, and across projects (no scope, the tenant, or an org) it counts only the projects the caller may read, and records with no project; a tenant admin's counts every project. Each group, and the total, carries its cost and its token sums (`tokens`). `groups` is ordered by `totalUsd`, highest first (ties by key), and capped at `limit` (default 1000): `truncated` and `totalGroups` say when there were more, and the totals still cover every record. For every record, page through `/v1/cost/records`. `inherit` has no effect on cost records, which always belong to a project.",
|
|
31578
31579
|
"parameters": [
|
|
31579
31580
|
{
|
|
31580
31581
|
"name": "groupBy",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/api",
|
|
3
|
-
"version": "0.1.5
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "REST + SSE HTTP surface for Kindgi™. createApp assembles a Hono app serving the /v1/* REST API (bearer or session-token auth; OpenAPI 3.1 document at /v1/openapi.json) and an optional S3-compatible /s3/* surface (SigV4), over caller-plugged bindings for the runtime (runs, agents, flows, HITL, supervisor) and for storage. Route conventions: docs/API-ROUTE-CONVENTIONS.md.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -37,30 +37,30 @@
|
|
|
37
37
|
"openapi.json"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@kindgi/agents": "0.1.5
|
|
41
|
-
"@kindgi/audit-events": "0.1.5
|
|
42
|
-
"@kindgi/compliance": "0.1.5
|
|
43
|
-
"@kindgi/authz": "0.1.5
|
|
44
|
-
"@kindgi/blob-binding": "0.1.5
|
|
45
|
-
"@kindgi/capabilities": "0.1.5
|
|
46
|
-
"@kindgi/crypto": "0.1.5
|
|
47
|
-
"@kindgi/flow": "0.1.5
|
|
48
|
-
"@kindgi/guardrails": "0.1.5
|
|
49
|
-
"@kindgi/log": "0.1.5
|
|
50
|
-
"@kindgi/memory": "0.1.5
|
|
51
|
-
"@kindgi/platform": "0.1.5
|
|
52
|
-
"@kindgi/provenance": "0.1.5
|
|
53
|
-
"@kindgi/runtime": "0.1.5
|
|
54
|
-
"@kindgi/policy-contract": "0.1.5
|
|
55
|
-
"@kindgi/schema": "0.1.5
|
|
56
|
-
"@kindgi/tools": "0.1.5
|
|
57
|
-
"@kindgi/types": "0.1.5
|
|
40
|
+
"@kindgi/agents": "0.1.5",
|
|
41
|
+
"@kindgi/audit-events": "0.1.5",
|
|
42
|
+
"@kindgi/compliance": "0.1.5",
|
|
43
|
+
"@kindgi/authz": "0.1.5",
|
|
44
|
+
"@kindgi/blob-binding": "0.1.5",
|
|
45
|
+
"@kindgi/capabilities": "0.1.5",
|
|
46
|
+
"@kindgi/crypto": "0.1.5",
|
|
47
|
+
"@kindgi/flow": "0.1.5",
|
|
48
|
+
"@kindgi/guardrails": "0.1.5",
|
|
49
|
+
"@kindgi/log": "0.1.5",
|
|
50
|
+
"@kindgi/memory": "0.1.5",
|
|
51
|
+
"@kindgi/platform": "0.1.5",
|
|
52
|
+
"@kindgi/provenance": "0.1.5",
|
|
53
|
+
"@kindgi/runtime": "0.1.5",
|
|
54
|
+
"@kindgi/policy-contract": "0.1.5",
|
|
55
|
+
"@kindgi/schema": "0.1.5",
|
|
56
|
+
"@kindgi/tools": "0.1.5",
|
|
57
|
+
"@kindgi/types": "0.1.5",
|
|
58
58
|
"@scalar/hono-api-reference": "^0.12.2",
|
|
59
59
|
"hono": "^4.6.14"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@kindgi/audit-events-inmemory": "0.1.5
|
|
63
|
-
"@kindgi/specs": "0.1.5
|
|
62
|
+
"@kindgi/audit-events-inmemory": "0.1.5",
|
|
63
|
+
"@kindgi/specs": "0.1.5",
|
|
64
64
|
"@types/aws4": "^1.11.6",
|
|
65
65
|
"@types/node": "^22.10.5",
|
|
66
66
|
"aws4": "^1.13.2",
|
package/src/app.ts
CHANGED
|
@@ -1386,7 +1386,7 @@ export function createApp(input: CreateAppInput): Hono<AppEnv> {
|
|
|
1386
1386
|
}),
|
|
1387
1387
|
);
|
|
1388
1388
|
if (input.cost !== undefined) {
|
|
1389
|
-
v1.route('/cost', costRouter(input.cost, authorizer));
|
|
1389
|
+
v1.route('/cost', costRouter(input.cost, authorizer, input.projectBinding));
|
|
1390
1390
|
}
|
|
1391
1391
|
if (input.adapterRegistry !== undefined) {
|
|
1392
1392
|
v1.route(
|
package/src/cost-binding.ts
CHANGED
|
@@ -50,6 +50,14 @@ export interface CostBinding {
|
|
|
50
50
|
* every group is capped by the route.
|
|
51
51
|
*/
|
|
52
52
|
aggregate(input: CostAggregateInput): Promise<CostAggregateResult>;
|
|
53
|
+
/**
|
|
54
|
+
* `true` when `aggregate` honours `CostAggregateInput.readableProjectIds`,
|
|
55
|
+
* counting only those projects' records. Without it, the route answers
|
|
56
|
+
* an aggregate across projects (no scope, or an org) only for a caller
|
|
57
|
+
* who may read every project in it, and refuses anyone else rather than
|
|
58
|
+
* count projects they can't read.
|
|
59
|
+
*/
|
|
60
|
+
readonly aggregatesReadableProjects?: boolean;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
// A binding throws when its store fails: the route answers 500. It never
|
|
@@ -198,6 +206,17 @@ export interface CostAggregateInput {
|
|
|
198
206
|
* shape uniformity.
|
|
199
207
|
*/
|
|
200
208
|
readonly inherit?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Count only records in these projects, and records with no project
|
|
211
|
+
* (the tenant's own, which `/v1/cost/records` shows to anyone who may
|
|
212
|
+
* read the tenant), within `scope`. An org scope covers only the org's
|
|
213
|
+
* projects: a record with no project never counts there, and nor does a
|
|
214
|
+
* listed project outside the org. The route sets it when the caller
|
|
215
|
+
* may not read every project (`read` on each listed one); absent, every
|
|
216
|
+
* record in `scope` counts. Empty: no project's records count. A binding
|
|
217
|
+
* that applies it says so with `CostBinding.aggregatesReadableProjects`.
|
|
218
|
+
*/
|
|
219
|
+
readonly readableProjectIds?: readonly string[];
|
|
201
220
|
/**
|
|
202
221
|
* The most groups the caller wants (`?limit=`, 1..`COST_AGGREGATE_MAX_LIMIT`,
|
|
203
222
|
* default `COST_AGGREGATE_DEFAULT_LIMIT`): the most expensive ones. A
|
package/src/errors.ts
CHANGED
|
@@ -94,6 +94,8 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
|
|
|
94
94
|
'tool-already-registered': 409,
|
|
95
95
|
'tool-project-mismatch': 409,
|
|
96
96
|
'guardrail-already-registered': 409,
|
|
97
|
+
// A deploy's guardrail id is live in another project: a deploy never takes it.
|
|
98
|
+
'guardrail-project-mismatch': 409,
|
|
97
99
|
'guardrail-config-invalid': 422,
|
|
98
100
|
'flow-already-registered': 409,
|
|
99
101
|
'flow-project-mismatch': 409,
|
|
@@ -61,8 +61,11 @@ export interface Authorizer {
|
|
|
61
61
|
) => Promise<T[]>;
|
|
62
62
|
/**
|
|
63
63
|
* The ids of every object of `type` the caller may `action`
|
|
64
|
-
* (`AuthzCheckBinding.listObjects`)
|
|
65
|
-
*
|
|
64
|
+
* (`AuthzCheckBinding.listObjects`), within its API key's limits as
|
|
65
|
+
* `filterByCan` holds them: a key limited to a project lists no project
|
|
66
|
+
* but its own, and other types as a check decides them (it still reads
|
|
67
|
+
* the orgs its user reads); `undefined` when the binding can't list, so
|
|
68
|
+
* the caller falls back to `filterByCan`.
|
|
66
69
|
*/
|
|
67
70
|
readonly listObjects?: (
|
|
68
71
|
c: Context<AppEnv>,
|
|
@@ -199,7 +202,20 @@ export function createAuthorizer(binding: AuthzCheckBinding): Authorizer {
|
|
|
199
202
|
typeof requestId === 'string' && requestId.length > 0
|
|
200
203
|
? { correlationId: requestId }
|
|
201
204
|
: undefined;
|
|
202
|
-
|
|
205
|
+
const ids = await binding.listObjects(principal, action, type, ctx);
|
|
206
|
+
// The key's own limits, as `filterByCan` holds them: the store lists
|
|
207
|
+
// what the user may do, and a key limited to a project lists no other
|
|
208
|
+
// project (other types as a check decides them).
|
|
209
|
+
const withinKey = await Promise.all(
|
|
210
|
+
ids.map(async (id) => {
|
|
211
|
+
const resource: ResourceRef = { type, id };
|
|
212
|
+
return (
|
|
213
|
+
keyCeilingDeny(c, action, resource) === undefined &&
|
|
214
|
+
(await inKeyProject(c, principal, resource))
|
|
215
|
+
);
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
return ids.filter((_, i) => withinKey[i]);
|
|
203
219
|
},
|
|
204
220
|
};
|
|
205
221
|
}
|
|
@@ -4306,7 +4306,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
4306
4306
|
operationId: 'cost.aggregate',
|
|
4307
4307
|
summary: 'Aggregate cost across a time window',
|
|
4308
4308
|
description:
|
|
4309
|
-
"Primary consumer path for dashboards. `groupBy` is required (comma-separated dimensions from the closed set); time range is required (both `from` and `to`, or both omitted for the default last-30-days window echoed back in `timeRange`). Filters compose on top of the time window. `?scopeKind + ?scopeId` narrow the aggregate to a scope: `org` covers every project in the org, so one call sums an org's spend. Each group, and the total, carries its cost and its token sums (`tokens`). `groups` is ordered by `totalUsd`, highest first (ties by key), and capped at `limit` (default 1000): `truncated` and `totalGroups` say when there were more, and the totals still cover every record. For every record, page through `/v1/cost/records`. `inherit` has no effect on cost records, which always belong to a project.",
|
|
4309
|
+
"Primary consumer path for dashboards. `groupBy` is required (comma-separated dimensions from the closed set); time range is required (both `from` and `to`, or both omitted for the default last-30-days window echoed back in `timeRange`). Filters compose on top of the time window. `?scopeKind + ?scopeId` narrow the aggregate to a scope: `org` covers every project in the org, so one call sums an org's spend. It needs `read` on the scope, and across projects (no scope, the tenant, or an org) it counts only the projects the caller may read, and records with no project; a tenant admin's counts every project. Each group, and the total, carries its cost and its token sums (`tokens`). `groups` is ordered by `totalUsd`, highest first (ties by key), and capped at `limit` (default 1000): `truncated` and `totalGroups` say when there were more, and the totals still cover every record. For every record, page through `/v1/cost/records`. `inherit` has no effect on cost records, which always belong to a project.",
|
|
4310
4310
|
tags: ['cost'],
|
|
4311
4311
|
security: 'bearer',
|
|
4312
4312
|
parameters: [
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -1618,7 +1618,7 @@ export const RetrievalIntentSchema: JsonSchema = {
|
|
|
1618
1618
|
type: 'string',
|
|
1619
1619
|
enum: ['same-conversation', 'same-user', 'same-segment', 'same-project', 'tenant'],
|
|
1620
1620
|
description:
|
|
1621
|
-
"What the intent selects within what the run may see. Facts: this conversation's;
|
|
1621
|
+
"What the intent selects within what the run may see. Facts: this conversation's; the run's end user's only (`same-user`: none when the run names no `participantId`); the run's project's (none without a project); or every fact of the type it may see (`tenant`). Conversations: this end user's other conversations with the agent (`same-user`: none when the run names no `participantId`); this conversation's messages older than the history window (`same-conversation`); conversations in the run's segment path (`same-segment`) or its project (`same-project`), whoever had them: those two quote other people's conversations, so publishing warns and their messages are marked as another person's. `same-segment` is for conversations only, `tenant` for facts only.",
|
|
1622
1622
|
},
|
|
1623
1623
|
limit: { type: 'integer', minimum: 1 },
|
|
1624
1624
|
mode: {
|
package/src/publish-refused.ts
CHANGED
|
@@ -19,6 +19,17 @@ type RefusedOutcome = Exclude<
|
|
|
19
19
|
{ readonly kind: 'ok' | 'already-registered' }
|
|
20
20
|
>;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A refusal the deploy decides itself, when its registry answered
|
|
24
|
+
* `already-registered` for a live guardrail it can't keep: one in another
|
|
25
|
+
* project (whose project the registry doesn't say), or one in its own
|
|
26
|
+
* project with a different definition.
|
|
27
|
+
*/
|
|
28
|
+
interface DeployRefusal {
|
|
29
|
+
readonly code: 'guardrail-project-mismatch' | 'guardrail-already-registered';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* A deploy's primitive came back from its registry with an outcome other
|
|
24
35
|
* than `ok` or `already-registered` (e.g. `project-not-found`): the deploy
|
|
@@ -35,15 +46,22 @@ export class PublishRefused extends Error {
|
|
|
35
46
|
/** The code the deploy answers with. */
|
|
36
47
|
readonly code:
|
|
37
48
|
| Exclude<RefusedOutcome['kind'], 'project-mismatch'>
|
|
38
|
-
| `${PublishedPrimitive}-project-mismatch
|
|
49
|
+
| `${PublishedPrimitive}-project-mismatch`
|
|
50
|
+
| DeployRefusal['code'];
|
|
39
51
|
/** With `…-project-mismatch`: the project the primitive belongs to, for the log only. */
|
|
40
52
|
readonly projectId?: ProjectId;
|
|
41
53
|
|
|
42
54
|
constructor(
|
|
43
55
|
readonly primitive: PublishedPrimitive,
|
|
44
56
|
readonly id: string,
|
|
45
|
-
outcome: RefusedOutcome,
|
|
57
|
+
outcome: RefusedOutcome | DeployRefusal,
|
|
46
58
|
) {
|
|
59
|
+
if ('code' in outcome) {
|
|
60
|
+
super(`The ${primitive} ${id} wasn't published: ${outcome.reason}`);
|
|
61
|
+
this.name = 'PublishRefused';
|
|
62
|
+
this.code = outcome.code;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
47
65
|
const code =
|
|
48
66
|
outcome.kind === 'project-mismatch'
|
|
49
67
|
? (`${primitive}-project-mismatch` as const)
|
package/src/routes/cost.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
|
-
import { Hono } from 'hono';
|
|
4
|
+
import { type Context, Hono } from 'hono';
|
|
5
5
|
|
|
6
6
|
import type { Cursor, TenantId, Timestamp } from '@kindgi/types';
|
|
7
7
|
|
|
8
|
-
import { type ResourceRef, ref } from '@kindgi/authz';
|
|
8
|
+
import { type ResourceRef, denyPayload, ref } from '@kindgi/authz';
|
|
9
|
+
import type { ProjectBinding } from '@kindgi/platform';
|
|
9
10
|
import {
|
|
10
11
|
COST_AGGREGATE_DEFAULT_LIMIT,
|
|
11
12
|
COST_AGGREGATE_MAX_LIMIT,
|
|
12
13
|
COST_GROUP_DIMENSIONS,
|
|
13
14
|
type CostAggregateGroup,
|
|
15
|
+
type CostAggregateInput,
|
|
14
16
|
type CostBinding,
|
|
15
17
|
type CostGroupDimension,
|
|
16
18
|
type CostRecord,
|
|
@@ -23,6 +25,7 @@ import type { Authorizer } from '../middleware/authorize.js';
|
|
|
23
25
|
import type { AppEnv } from '../types.js';
|
|
24
26
|
import { deniedBy } from './denied.js';
|
|
25
27
|
import { clampLimit } from './pagination.js';
|
|
28
|
+
import { projectIdsCallerMay } from './readable-projects.js';
|
|
26
29
|
import { parseScopeParams, scopeResourceRef } from './scope-params.js';
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -45,10 +48,14 @@ import { parseScopeParams, scopeResourceRef } from './scope-params.js';
|
|
|
45
48
|
export function costRouter(
|
|
46
49
|
binding: CostBinding,
|
|
47
50
|
/**
|
|
48
|
-
* With one
|
|
49
|
-
*
|
|
51
|
+
* With one: a record needs `read` on its project (the tenant, for one
|
|
52
|
+
* with no project); an aggregate, `read` on the scope it's asked for,
|
|
53
|
+
* and across projects (no scope, or an org) it counts only the projects
|
|
54
|
+
* the caller may read, unless the caller is a tenant admin.
|
|
50
55
|
*/
|
|
51
56
|
authorizer?: Authorizer,
|
|
57
|
+
/** The tenant's projects, to check one by one when the authorizer can't list them. */
|
|
58
|
+
projects?: Pick<ProjectBinding, 'list'>,
|
|
52
59
|
): Hono<AppEnv> {
|
|
53
60
|
const r = new Hono<AppEnv>();
|
|
54
61
|
const recordRef = (tenantId: TenantId, rec: { readonly projectId?: unknown }): ResourceRef =>
|
|
@@ -278,6 +285,16 @@ export function costRouter(
|
|
|
278
285
|
);
|
|
279
286
|
if (refused !== undefined) return refused;
|
|
280
287
|
|
|
288
|
+
// Across projects, only what the caller may read counts (`aggregateReach`).
|
|
289
|
+
const reach = await aggregateReach(c, {
|
|
290
|
+
binding,
|
|
291
|
+
tenantId,
|
|
292
|
+
authorizer,
|
|
293
|
+
projects,
|
|
294
|
+
scope: scopeParsed.scope,
|
|
295
|
+
});
|
|
296
|
+
if (reach.kind === 'refused') return reach.response;
|
|
297
|
+
|
|
281
298
|
const result = await binding.aggregate({
|
|
282
299
|
tenantId,
|
|
283
300
|
groupBy,
|
|
@@ -286,6 +303,7 @@ export function costRouter(
|
|
|
286
303
|
...(Object.keys(filter.value).length > 0 && { filter: filter.value }),
|
|
287
304
|
...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
|
|
288
305
|
...(scopeParsed.inherit !== undefined && { inherit: scopeParsed.inherit }),
|
|
306
|
+
...(reach.kind === 'projects' && { readableProjectIds: reach.ids }),
|
|
289
307
|
limit,
|
|
290
308
|
});
|
|
291
309
|
|
|
@@ -314,6 +332,65 @@ export function costRouter(
|
|
|
314
332
|
|
|
315
333
|
// -------- helpers --------
|
|
316
334
|
|
|
335
|
+
/**
|
|
336
|
+
* What an aggregate may count for this caller. Across projects (no scope,
|
|
337
|
+
* the tenant, or an org), `read` on the scope lets a member ask, not see
|
|
338
|
+
* every project's spend: only the projects they may read count, applied in
|
|
339
|
+
* the binding's query (`readableProjectIds`). A tenant admin's, a project
|
|
340
|
+
* scope's (already checked) and one without authorization count everything
|
|
341
|
+
* the scope holds. When the readable projects can't be worked out, or the
|
|
342
|
+
* binding can't apply them, the aggregate is refused rather than
|
|
343
|
+
* over-counted.
|
|
344
|
+
*/
|
|
345
|
+
async function aggregateReach(
|
|
346
|
+
c: Context<AppEnv>,
|
|
347
|
+
args: {
|
|
348
|
+
readonly binding: CostBinding;
|
|
349
|
+
readonly tenantId: TenantId;
|
|
350
|
+
readonly authorizer: Authorizer | undefined;
|
|
351
|
+
readonly projects: Pick<ProjectBinding, 'list'> | undefined;
|
|
352
|
+
readonly scope: CostAggregateInput['scope'] | undefined;
|
|
353
|
+
},
|
|
354
|
+
): Promise<
|
|
355
|
+
| { readonly kind: 'all' }
|
|
356
|
+
| { readonly kind: 'projects'; readonly ids: readonly string[] }
|
|
357
|
+
| { readonly kind: 'refused'; readonly response: Response }
|
|
358
|
+
> {
|
|
359
|
+
const { binding, tenantId, authorizer, projects, scope } = args;
|
|
360
|
+
if (authorizer === undefined || scope?.kind === 'project') return { kind: 'all' };
|
|
361
|
+
if (await authorizer.can(c, 'admin', ref('tenant', tenantId as unknown as string))) {
|
|
362
|
+
return { kind: 'all' };
|
|
363
|
+
}
|
|
364
|
+
const ids = await projectIdsCallerMay(c, authorizer, projects, 'read');
|
|
365
|
+
if (ids !== undefined && binding.aggregatesReadableProjects === true) {
|
|
366
|
+
return { kind: 'projects', ids };
|
|
367
|
+
}
|
|
368
|
+
const deny = denyPayload(
|
|
369
|
+
'read',
|
|
370
|
+
scope?.kind === 'org' ? 'org' : 'tenant',
|
|
371
|
+
scope?.kind === 'org' ? (scope.orgId as unknown as string) : (tenantId as unknown as string),
|
|
372
|
+
ids === undefined
|
|
373
|
+
? "the projects you may read can't be listed here, so an aggregate across projects can't be limited to them; ask per project (scopeKind=project)"
|
|
374
|
+
: "this deployment's cost store can't limit an aggregate to the projects you may read; ask per project (scopeKind=project)",
|
|
375
|
+
);
|
|
376
|
+
c.status(403);
|
|
377
|
+
return {
|
|
378
|
+
kind: 'refused',
|
|
379
|
+
response: c.json(
|
|
380
|
+
toWireError(
|
|
381
|
+
{
|
|
382
|
+
code: deny.code,
|
|
383
|
+
message: `Permission denied: ${deny.reason}`,
|
|
384
|
+
action: deny.action,
|
|
385
|
+
resource: deny.resource,
|
|
386
|
+
reason: deny.reason,
|
|
387
|
+
},
|
|
388
|
+
c.get('requestId'),
|
|
389
|
+
),
|
|
390
|
+
),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
317
394
|
function parseRecordFilter(
|
|
318
395
|
query: Readonly<Record<string, string | undefined>>,
|
|
319
396
|
opts: { skipTime?: boolean } = {},
|
|
@@ -616,7 +616,21 @@ export function deploymentsRouter(
|
|
|
616
616
|
rolled.push(async () => {
|
|
617
617
|
await bindings.guardrailRegistry?.unregister({ tenantId, guardrailId });
|
|
618
618
|
});
|
|
619
|
-
} else if (outcome.kind
|
|
619
|
+
} else if (outcome.kind === 'already-registered') {
|
|
620
|
+
const kept = await keepRegisteredGuardrail(
|
|
621
|
+
bindings.guardrailRegistry,
|
|
622
|
+
tenantId,
|
|
623
|
+
projectIdForGuardrail,
|
|
624
|
+
guardrail,
|
|
625
|
+
);
|
|
626
|
+
// Unregistered since the registry answered: registered afresh.
|
|
627
|
+
if (kept === 'registered') {
|
|
628
|
+
const guardrailId = guardrail.id;
|
|
629
|
+
rolled.push(async () => {
|
|
630
|
+
await bindings.guardrailRegistry?.unregister({ tenantId, guardrailId });
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
620
634
|
throw new PublishRefused('guardrail', guardrail.id, outcome);
|
|
621
635
|
}
|
|
622
636
|
}
|
|
@@ -1234,6 +1248,139 @@ interface DeployedImage {
|
|
|
1234
1248
|
readonly artifactVersion: string;
|
|
1235
1249
|
}
|
|
1236
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* A deploy keeps a guardrail id that's already live only when it's the
|
|
1253
|
+
* deploy's own: in the project the deploy registers into, with the same
|
|
1254
|
+
* definition (`sameGuardrailDefinition`, what its author declares). An id live in another project
|
|
1255
|
+
* is refused (`guardrail-project-mismatch`, its project never named): the
|
|
1256
|
+
* pack's agents would otherwise run that project's guardrail. One in this
|
|
1257
|
+
* project with another definition is refused too
|
|
1258
|
+
* (`guardrail-already-registered`): a deploy never changes a guardrail,
|
|
1259
|
+
* and keeping the old one would run what the pack no longer says.
|
|
1260
|
+
*
|
|
1261
|
+
* `get` answers a guardrail without its project, so whether the id is in
|
|
1262
|
+
* this project comes from the project's list. When the row is gone by the
|
|
1263
|
+
* time it's looked at (unregistered meanwhile), the guardrail is
|
|
1264
|
+
* registered again: `'registered'`, for the deploy to roll back.
|
|
1265
|
+
*/
|
|
1266
|
+
async function keepRegisteredGuardrail(
|
|
1267
|
+
registry: GuardrailRegistryBinding,
|
|
1268
|
+
tenantId: TenantId,
|
|
1269
|
+
projectId: ProjectId,
|
|
1270
|
+
guardrail: Guardrail,
|
|
1271
|
+
): Promise<'kept' | 'registered'> {
|
|
1272
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1273
|
+
const existing = await registry.get({ tenantId, guardrailId: guardrail.id });
|
|
1274
|
+
if (existing !== null) {
|
|
1275
|
+
if (!(await guardrailInProject(registry, tenantId, projectId, guardrail.id))) {
|
|
1276
|
+
throw new PublishRefused('guardrail', guardrail.id, {
|
|
1277
|
+
code: 'guardrail-project-mismatch',
|
|
1278
|
+
reason: 'it belongs to another project',
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
if (!sameGuardrailDefinition(existing, guardrail)) {
|
|
1282
|
+
throw new PublishRefused('guardrail', guardrail.id, {
|
|
1283
|
+
code: 'guardrail-already-registered',
|
|
1284
|
+
reason: `it is already registered with a different definition; unregister it (\`kindgi guardrails unregister ${guardrail.id}\`) and deploy again`,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
return 'kept';
|
|
1288
|
+
}
|
|
1289
|
+
const again = await registry.register({
|
|
1290
|
+
tenantId,
|
|
1291
|
+
projectId,
|
|
1292
|
+
guardrail,
|
|
1293
|
+
enqueueTuples: (guardrailId) =>
|
|
1294
|
+
tuplesForCreate({
|
|
1295
|
+
kind: 'guardrail',
|
|
1296
|
+
id: guardrailId as GuardrailId,
|
|
1297
|
+
tenantId,
|
|
1298
|
+
projectId,
|
|
1299
|
+
}),
|
|
1300
|
+
});
|
|
1301
|
+
if (again.kind === 'ok') return 'registered';
|
|
1302
|
+
if (again.kind !== 'already-registered') {
|
|
1303
|
+
throw new PublishRefused('guardrail', guardrail.id, again);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
throw new Error(`guardrail ${guardrail.id}: registered and unregistered while deploying`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** Whether the live guardrail `id` is in `projectId`, by that project's list. */
|
|
1310
|
+
async function guardrailInProject(
|
|
1311
|
+
registry: GuardrailRegistryBinding,
|
|
1312
|
+
tenantId: TenantId,
|
|
1313
|
+
projectId: ProjectId,
|
|
1314
|
+
id: GuardrailId,
|
|
1315
|
+
): Promise<boolean> {
|
|
1316
|
+
let cursor: Cursor | undefined;
|
|
1317
|
+
do {
|
|
1318
|
+
const page = await registry.list({
|
|
1319
|
+
tenantId,
|
|
1320
|
+
limit: 100,
|
|
1321
|
+
nameFilter: id,
|
|
1322
|
+
scope: { kind: 'project', tenantId, projectId },
|
|
1323
|
+
...(cursor !== undefined && { cursor }),
|
|
1324
|
+
});
|
|
1325
|
+
if (page.data.some((g) => g.id === id)) return true;
|
|
1326
|
+
cursor = page.nextCursor;
|
|
1327
|
+
} while (cursor !== undefined);
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* The fields of a guardrail its author declares, which a deploy compares.
|
|
1333
|
+
* Never compared: what a deploy or a release derives. That includes
|
|
1334
|
+
* `configSchema`, which the deploy route dropped before 0.1.5, so a row a
|
|
1335
|
+
* 0.1.4 deploy stored has none; the image and artifact version a code
|
|
1336
|
+
* pointer names, which every new image changes; and any field a later
|
|
1337
|
+
* release adds. So an unchanged pack redeploys across releases.
|
|
1338
|
+
*/
|
|
1339
|
+
const DECLARED_GUARDRAIL_FIELDS = [
|
|
1340
|
+
'name',
|
|
1341
|
+
'description',
|
|
1342
|
+
'kind',
|
|
1343
|
+
'check',
|
|
1344
|
+
'config',
|
|
1345
|
+
'action',
|
|
1346
|
+
'severity',
|
|
1347
|
+
'scope',
|
|
1348
|
+
'budget',
|
|
1349
|
+
'judgeCapabilities',
|
|
1350
|
+
'sandbox',
|
|
1351
|
+
'limits',
|
|
1352
|
+
'network',
|
|
1353
|
+
'needsSpec',
|
|
1354
|
+
] as const;
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* The same guardrail definition: equal in what its author declares
|
|
1358
|
+
* (`DECLARED_GUARDRAIL_FIELDS`, and of where its code lives only the
|
|
1359
|
+
* module path), as JSON with keys in any order (a registry may store it as
|
|
1360
|
+
* JSONB) and an absent field the same as an `undefined` one.
|
|
1361
|
+
*/
|
|
1362
|
+
export function sameGuardrailDefinition(a: Guardrail, b: Guardrail): boolean {
|
|
1363
|
+
return canonicalJson(definitionOf(a)) === canonicalJson(definitionOf(b));
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function definitionOf(guardrail: Guardrail): Record<string, unknown> {
|
|
1367
|
+
const declared: Record<string, unknown> = {};
|
|
1368
|
+
for (const field of DECLARED_GUARDRAIL_FIELDS) {
|
|
1369
|
+
declared[field] = (guardrail as unknown as Record<string, unknown>)[field];
|
|
1370
|
+
}
|
|
1371
|
+
declared.modulePath = guardrail.codeArtifactRef?.modulePath;
|
|
1372
|
+
return declared;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** JSON with every object's keys sorted, and absent and `undefined` alike. */
|
|
1376
|
+
function canonicalJson(value: unknown): string {
|
|
1377
|
+
return JSON.stringify(JSON.parse(JSON.stringify(value) ?? 'null'), (_key, v: unknown) =>
|
|
1378
|
+
v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
1379
|
+
? Object.fromEntries(Object.entries(v).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)))
|
|
1380
|
+
: v,
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1237
1384
|
/** The pointer to a tool's or guardrail's module inside the deployed image. */
|
|
1238
1385
|
function ociCodeArtifactRef(image: DeployedImage, modulePath: unknown) {
|
|
1239
1386
|
return typeof modulePath === 'string' && modulePath.length > 0
|
package/src/routes/eval-runs.ts
CHANGED
|
@@ -173,7 +173,10 @@ function startRouter(
|
|
|
173
173
|
agentRef !== undefined
|
|
174
174
|
? ref('agent', agentRef.agentId as unknown as string)
|
|
175
175
|
: ref('flow', flowRef?.flowId as unknown as string);
|
|
176
|
+
// Running the suite takes `execute` on it (an editor of its project, or
|
|
177
|
+
// an executor), whichever project the run lands in.
|
|
176
178
|
const refused =
|
|
179
|
+
(await deniedBy(authorizer, c, 'execute', ref('eval_suite', suiteId))) ??
|
|
177
180
|
(await deniedBy(authorizer, c, 'write', ref('project', projectId as unknown as string))) ??
|
|
178
181
|
(await deniedBy(authorizer, c, 'execute', target));
|
|
179
182
|
if (refused !== undefined) return refused;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
+
import { routePath } from 'hono/route';
|
|
5
6
|
|
|
6
7
|
import { type Principal, ref, tuplesForCreate } from '@kindgi/authz';
|
|
7
8
|
import type { Cursor, ProjectId, TenantId, UserId } from '@kindgi/types';
|
|
@@ -76,8 +77,13 @@ export function evalSuitesRouter(
|
|
|
76
77
|
return mw(c, next);
|
|
77
78
|
});
|
|
78
79
|
r.use('/:suiteId/*', async (c, next) => {
|
|
79
|
-
const action = c.req.method === 'GET' ? 'read' : 'admin';
|
|
80
80
|
const suiteId = c.req.param('suiteId') ?? '';
|
|
81
|
+
// Starting a run (`POST /:suiteId/runs`, exactly) doesn't change the
|
|
82
|
+
// suite, and the start route checks it all itself: `execute` on the
|
|
83
|
+
// suite, `write` on the project and `execute` on the agent or flow.
|
|
84
|
+
const under = c.req.path.split('/').slice(routePath(c).split('/').length - 1);
|
|
85
|
+
if (c.req.method === 'POST' && under.length === 1 && under[0] === 'runs') return next();
|
|
86
|
+
const action = c.req.method === 'GET' ? 'read' : 'admin';
|
|
81
87
|
// A test set built from judgments under a suite id never registered
|
|
82
88
|
// (no head row) has no suite to check yet: the build checks `admin`
|
|
83
89
|
// on the project it names, the project the new suite belongs to. A
|