@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.
Files changed (55) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/cost-binding.d.ts +19 -0
  4. package/dist/cost-binding.d.ts.map +1 -1
  5. package/dist/cost-binding.js.map +1 -1
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +2 -0
  8. package/dist/errors.js.map +1 -1
  9. package/dist/middleware/authorize.d.ts +5 -2
  10. package/dist/middleware/authorize.d.ts.map +1 -1
  11. package/dist/middleware/authorize.js +10 -1
  12. package/dist/middleware/authorize.js.map +1 -1
  13. package/dist/openapi/operations.js +1 -1
  14. package/dist/openapi/operations.js.map +1 -1
  15. package/dist/openapi/schemas.js +1 -1
  16. package/dist/openapi/schemas.js.map +1 -1
  17. package/dist/publish-refused.d.ts +12 -2
  18. package/dist/publish-refused.d.ts.map +1 -1
  19. package/dist/publish-refused.js +6 -0
  20. package/dist/publish-refused.js.map +1 -1
  21. package/dist/routes/cost.d.ts +8 -3
  22. package/dist/routes/cost.d.ts.map +1 -1
  23. package/dist/routes/cost.js +56 -4
  24. package/dist/routes/cost.js.map +1 -1
  25. package/dist/routes/deployments.d.ts +8 -0
  26. package/dist/routes/deployments.d.ts.map +1 -1
  27. package/dist/routes/deployments.js +127 -1
  28. package/dist/routes/deployments.js.map +1 -1
  29. package/dist/routes/eval-runs.js +4 -1
  30. package/dist/routes/eval-runs.js.map +1 -1
  31. package/dist/routes/eval-suites.d.ts.map +1 -1
  32. package/dist/routes/eval-suites.js +8 -1
  33. package/dist/routes/eval-suites.js.map +1 -1
  34. package/dist/routes/memory-access.d.ts.map +1 -1
  35. package/dist/routes/memory-access.js +18 -33
  36. package/dist/routes/memory-access.js.map +1 -1
  37. package/dist/routes/readable-projects.d.ts +26 -0
  38. package/dist/routes/readable-projects.d.ts.map +1 -0
  39. package/dist/routes/readable-projects.js +39 -0
  40. package/dist/routes/readable-projects.js.map +1 -0
  41. package/openapi.json +3 -2
  42. package/package.json +21 -21
  43. package/src/app.ts +1 -1
  44. package/src/cost-binding.ts +19 -0
  45. package/src/errors.ts +2 -0
  46. package/src/middleware/authorize.ts +19 -3
  47. package/src/openapi/operations.ts +1 -1
  48. package/src/openapi/schemas.ts +1 -1
  49. package/src/publish-refused.ts +20 -2
  50. package/src/routes/cost.ts +81 -4
  51. package/src/routes/deployments.ts +148 -1
  52. package/src/routes/eval-runs.ts +3 -0
  53. package/src/routes/eval-suites.ts +7 -1
  54. package/src/routes/memory-access.ts +19 -38
  55. 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; this run's end user's and user's; the run's project's (none without a project); or every fact of the type it may see (`tenant`). Conversations: this person's other conversations with the agent (`same-user`); 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
+ "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-rc.0",
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-rc.0",
41
- "@kindgi/audit-events": "0.1.5-rc.0",
42
- "@kindgi/compliance": "0.1.5-rc.0",
43
- "@kindgi/authz": "0.1.5-rc.0",
44
- "@kindgi/blob-binding": "0.1.5-rc.0",
45
- "@kindgi/capabilities": "0.1.5-rc.0",
46
- "@kindgi/crypto": "0.1.5-rc.0",
47
- "@kindgi/flow": "0.1.5-rc.0",
48
- "@kindgi/guardrails": "0.1.5-rc.0",
49
- "@kindgi/log": "0.1.5-rc.0",
50
- "@kindgi/memory": "0.1.5-rc.0",
51
- "@kindgi/platform": "0.1.5-rc.0",
52
- "@kindgi/provenance": "0.1.5-rc.0",
53
- "@kindgi/runtime": "0.1.5-rc.0",
54
- "@kindgi/policy-contract": "0.1.5-rc.0",
55
- "@kindgi/schema": "0.1.5-rc.0",
56
- "@kindgi/tools": "0.1.5-rc.0",
57
- "@kindgi/types": "0.1.5-rc.0",
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-rc.0",
63
- "@kindgi/specs": "0.1.5-rc.0",
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(
@@ -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`); `undefined` when the binding
65
- * can't list, so the caller falls back to `filterByCan`.
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
- return binding.listObjects(principal, action, type, ctx);
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: [
@@ -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; this run's end user's and user's; the run's project's (none without a project); or every fact of the type it may see (`tenant`). Conversations: this person's other conversations with the agent (`same-user`); 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.",
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: {
@@ -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)
@@ -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 (T243 A): a record needs `read` on its project (the tenant,
49
- * for one with no project); an aggregate, on the scope it's asked for.
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 !== 'already-registered') {
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
@@ -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