@kindgi/api 0.1.2 → 0.1.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/dist/agent-binding.d.ts +18 -4
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts +48 -0
  4. package/dist/agent-pins.d.ts.map +1 -0
  5. package/dist/agent-pins.js +102 -0
  6. package/dist/agent-pins.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +22 -3
  10. package/dist/app.js.map +1 -1
  11. package/dist/block-binding.d.ts +132 -0
  12. package/dist/block-binding.d.ts.map +1 -0
  13. package/dist/block-binding.js +4 -0
  14. package/dist/block-binding.js.map +1 -0
  15. package/dist/block-pins.d.ts +22 -0
  16. package/dist/block-pins.d.ts.map +1 -0
  17. package/dist/block-pins.js +112 -0
  18. package/dist/block-pins.js.map +1 -0
  19. package/dist/cost-binding.d.ts +100 -5
  20. package/dist/cost-binding.d.ts.map +1 -1
  21. package/dist/cost-binding.js +10 -0
  22. package/dist/cost-binding.js.map +1 -1
  23. package/dist/deploy-versions.d.ts +58 -0
  24. package/dist/deploy-versions.d.ts.map +1 -0
  25. package/dist/deploy-versions.js +91 -0
  26. package/dist/deploy-versions.js.map +1 -0
  27. package/dist/deployment-binding.d.ts +24 -3
  28. package/dist/deployment-binding.d.ts.map +1 -1
  29. package/dist/derive-agent-version.d.ts +69 -0
  30. package/dist/derive-agent-version.d.ts.map +1 -0
  31. package/dist/derive-agent-version.js +139 -0
  32. package/dist/derive-agent-version.js.map +1 -0
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +17 -0
  35. package/dist/errors.js.map +1 -1
  36. package/dist/eval-case-binding.d.ts +65 -0
  37. package/dist/eval-case-binding.d.ts.map +1 -0
  38. package/dist/eval-case-binding.js +4 -0
  39. package/dist/eval-case-binding.js.map +1 -0
  40. package/dist/eval-run-binding.d.ts +30 -0
  41. package/dist/eval-run-binding.d.ts.map +1 -1
  42. package/dist/eval-run-dispatcher.d.ts +38 -3
  43. package/dist/eval-run-dispatcher.d.ts.map +1 -1
  44. package/dist/eval-run-dispatcher.js +21 -15
  45. package/dist/eval-run-dispatcher.js.map +1 -1
  46. package/dist/eval-suite-binding.d.ts +1 -1
  47. package/dist/eval-suite-binding.d.ts.map +1 -1
  48. package/dist/eval-suite-binding.js +2 -0
  49. package/dist/eval-suite-binding.js.map +1 -1
  50. package/dist/flow-binding.d.ts +10 -4
  51. package/dist/flow-binding.d.ts.map +1 -1
  52. package/dist/flow-pins.d.ts +36 -0
  53. package/dist/flow-pins.d.ts.map +1 -0
  54. package/dist/flow-pins.js +81 -0
  55. package/dist/flow-pins.js.map +1 -0
  56. package/dist/hitl-binding.d.ts +20 -5
  57. package/dist/hitl-binding.d.ts.map +1 -1
  58. package/dist/index.d.ts +19 -8
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +8 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/judged-dispatcher.d.ts +134 -0
  63. package/dist/judged-dispatcher.d.ts.map +1 -0
  64. package/dist/judged-dispatcher.js +297 -0
  65. package/dist/judged-dispatcher.js.map +1 -0
  66. package/dist/judged-items.d.ts +86 -0
  67. package/dist/judged-items.d.ts.map +1 -0
  68. package/dist/judged-items.js +184 -0
  69. package/dist/judged-items.js.map +1 -0
  70. package/dist/judgment-binding.d.ts +316 -0
  71. package/dist/judgment-binding.d.ts.map +1 -0
  72. package/dist/judgment-binding.js +19 -0
  73. package/dist/judgment-binding.js.map +1 -0
  74. package/dist/middleware/auth.d.ts +3 -2
  75. package/dist/middleware/auth.d.ts.map +1 -1
  76. package/dist/middleware/auth.js.map +1 -1
  77. package/dist/middleware/idempotency.d.ts +5 -1
  78. package/dist/middleware/idempotency.d.ts.map +1 -1
  79. package/dist/middleware/idempotency.js +8 -1
  80. package/dist/middleware/idempotency.js.map +1 -1
  81. package/dist/openapi/generate.d.ts.map +1 -1
  82. package/dist/openapi/generate.js +4 -1
  83. package/dist/openapi/generate.js.map +1 -1
  84. package/dist/openapi/operations.d.ts.map +1 -1
  85. package/dist/openapi/operations.js +543 -24
  86. package/dist/openapi/operations.js.map +1 -1
  87. package/dist/openapi/schemas.d.ts +58 -0
  88. package/dist/openapi/schemas.d.ts.map +1 -1
  89. package/dist/openapi/schemas.js +1058 -27
  90. package/dist/openapi/schemas.js.map +1 -1
  91. package/dist/provenance-binding.d.ts +27 -1
  92. package/dist/provenance-binding.d.ts.map +1 -1
  93. package/dist/provenance-binding.js.map +1 -1
  94. package/dist/provider-binding.d.ts +12 -7
  95. package/dist/provider-binding.d.ts.map +1 -1
  96. package/dist/reviewer-binding.d.ts +10 -2
  97. package/dist/reviewer-binding.d.ts.map +1 -1
  98. package/dist/reviewer-role.d.ts +13 -0
  99. package/dist/reviewer-role.d.ts.map +1 -0
  100. package/dist/reviewer-role.js +27 -0
  101. package/dist/reviewer-role.js.map +1 -0
  102. package/dist/routes/agents.d.ts +9 -1
  103. package/dist/routes/agents.d.ts.map +1 -1
  104. package/dist/routes/agents.js +175 -11
  105. package/dist/routes/agents.js.map +1 -1
  106. package/dist/routes/approvals.d.ts +5 -4
  107. package/dist/routes/approvals.d.ts.map +1 -1
  108. package/dist/routes/approvals.js +52 -16
  109. package/dist/routes/approvals.js.map +1 -1
  110. package/dist/routes/blocks.d.ts +19 -0
  111. package/dist/routes/blocks.d.ts.map +1 -0
  112. package/dist/routes/blocks.js +281 -0
  113. package/dist/routes/blocks.js.map +1 -0
  114. package/dist/routes/conversations.d.ts +7 -1
  115. package/dist/routes/conversations.d.ts.map +1 -1
  116. package/dist/routes/conversations.js +41 -1
  117. package/dist/routes/conversations.js.map +1 -1
  118. package/dist/routes/cost.d.ts.map +1 -1
  119. package/dist/routes/cost.js +142 -11
  120. package/dist/routes/cost.js.map +1 -1
  121. package/dist/routes/deployments.d.ts +3 -0
  122. package/dist/routes/deployments.d.ts.map +1 -1
  123. package/dist/routes/deployments.js +208 -55
  124. package/dist/routes/deployments.js.map +1 -1
  125. package/dist/routes/eval-comparison.d.ts +14 -0
  126. package/dist/routes/eval-comparison.d.ts.map +1 -0
  127. package/dist/routes/eval-comparison.js +87 -0
  128. package/dist/routes/eval-comparison.js.map +1 -0
  129. package/dist/routes/eval-runs.d.ts.map +1 -1
  130. package/dist/routes/eval-runs.js +8 -0
  131. package/dist/routes/eval-runs.js.map +1 -1
  132. package/dist/routes/flows.d.ts +13 -1
  133. package/dist/routes/flows.d.ts.map +1 -1
  134. package/dist/routes/flows.js +44 -3
  135. package/dist/routes/flows.js.map +1 -1
  136. package/dist/routes/hierarchy-errors.d.ts +35 -0
  137. package/dist/routes/hierarchy-errors.d.ts.map +1 -0
  138. package/dist/routes/hierarchy-errors.js +39 -0
  139. package/dist/routes/hierarchy-errors.js.map +1 -0
  140. package/dist/routes/identity.d.ts +7 -0
  141. package/dist/routes/identity.d.ts.map +1 -1
  142. package/dist/routes/identity.js +3 -2
  143. package/dist/routes/identity.js.map +1 -1
  144. package/dist/routes/judged-suites.d.ts +20 -0
  145. package/dist/routes/judged-suites.d.ts.map +1 -0
  146. package/dist/routes/judged-suites.js +272 -0
  147. package/dist/routes/judged-suites.js.map +1 -0
  148. package/dist/routes/judgment-context.d.ts +22 -0
  149. package/dist/routes/judgment-context.d.ts.map +1 -0
  150. package/dist/routes/judgment-context.js +88 -0
  151. package/dist/routes/judgment-context.js.map +1 -0
  152. package/dist/routes/judgment-flow-context.d.ts +32 -0
  153. package/dist/routes/judgment-flow-context.d.ts.map +1 -0
  154. package/dist/routes/judgment-flow-context.js +195 -0
  155. package/dist/routes/judgment-flow-context.js.map +1 -0
  156. package/dist/routes/judgments.d.ts +41 -0
  157. package/dist/routes/judgments.d.ts.map +1 -0
  158. package/dist/routes/judgments.js +566 -0
  159. package/dist/routes/judgments.js.map +1 -0
  160. package/dist/routes/orgs.d.ts +5 -2
  161. package/dist/routes/orgs.d.ts.map +1 -1
  162. package/dist/routes/orgs.js +38 -22
  163. package/dist/routes/orgs.js.map +1 -1
  164. package/dist/routes/policies.d.ts.map +1 -1
  165. package/dist/routes/policies.js +12 -1
  166. package/dist/routes/policies.js.map +1 -1
  167. package/dist/routes/projects.d.ts +10 -2
  168. package/dist/routes/projects.d.ts.map +1 -1
  169. package/dist/routes/projects.js +87 -79
  170. package/dist/routes/projects.js.map +1 -1
  171. package/dist/routes/provenance.d.ts.map +1 -1
  172. package/dist/routes/provenance.js +32 -1
  173. package/dist/routes/provenance.js.map +1 -1
  174. package/dist/routes/providers.d.ts.map +1 -1
  175. package/dist/routes/providers.js +6 -1
  176. package/dist/routes/providers.js.map +1 -1
  177. package/dist/routes/runs.d.ts +0 -7
  178. package/dist/routes/runs.d.ts.map +1 -1
  179. package/dist/routes/runs.js +65 -20
  180. package/dist/routes/runs.js.map +1 -1
  181. package/dist/routes/scope-params.d.ts +16 -1
  182. package/dist/routes/scope-params.d.ts.map +1 -1
  183. package/dist/routes/scope-params.js +28 -0
  184. package/dist/routes/scope-params.js.map +1 -1
  185. package/dist/routes/teams.d.ts +6 -2
  186. package/dist/routes/teams.d.ts.map +1 -1
  187. package/dist/routes/teams.js +77 -73
  188. package/dist/routes/teams.js.map +1 -1
  189. package/dist/types.d.ts +4 -3
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/webhook-endpoint-binding.d.ts +11 -0
  192. package/dist/webhook-endpoint-binding.d.ts.map +1 -1
  193. package/dist/webhook-endpoint-binding.js.map +1 -1
  194. package/openapi.json +13316 -9299
  195. package/package.json +21 -21
  196. package/src/agent-binding.ts +19 -4
  197. package/src/agent-pins.ts +147 -0
  198. package/src/app.ts +76 -3
  199. package/src/block-binding.ts +137 -0
  200. package/src/block-pins.ts +148 -0
  201. package/src/cost-binding.ts +116 -5
  202. package/src/deploy-versions.ts +157 -0
  203. package/src/deployment-binding.ts +27 -3
  204. package/src/derive-agent-version.ts +206 -0
  205. package/src/errors.ts +17 -0
  206. package/src/eval-case-binding.ts +71 -0
  207. package/src/eval-run-binding.ts +33 -0
  208. package/src/eval-run-dispatcher.ts +57 -16
  209. package/src/eval-suite-binding.ts +2 -0
  210. package/src/flow-binding.ts +11 -4
  211. package/src/flow-pins.ts +113 -0
  212. package/src/hitl-binding.ts +20 -4
  213. package/src/index.ts +88 -2
  214. package/src/judged-dispatcher.ts +507 -0
  215. package/src/judged-items.ts +263 -0
  216. package/src/judgment-binding.ts +349 -0
  217. package/src/middleware/auth.ts +3 -2
  218. package/src/middleware/idempotency.ts +7 -1
  219. package/src/openapi/generate.ts +7 -1
  220. package/src/openapi/operations.ts +615 -24
  221. package/src/openapi/schemas.ts +1157 -22
  222. package/src/provenance-binding.ts +42 -1
  223. package/src/provider-binding.ts +12 -7
  224. package/src/reviewer-binding.ts +10 -2
  225. package/src/reviewer-role.ts +35 -0
  226. package/src/routes/agents.ts +243 -19
  227. package/src/routes/approvals.ts +70 -19
  228. package/src/routes/blocks.ts +362 -0
  229. package/src/routes/conversations.ts +57 -1
  230. package/src/routes/cost.ts +159 -21
  231. package/src/routes/deployments.ts +266 -56
  232. package/src/routes/eval-comparison.ts +101 -0
  233. package/src/routes/eval-runs.ts +11 -0
  234. package/src/routes/flows.ts +63 -5
  235. package/src/routes/hierarchy-errors.ts +51 -0
  236. package/src/routes/identity.ts +10 -2
  237. package/src/routes/judged-suites.ts +363 -0
  238. package/src/routes/judgment-context.ts +128 -0
  239. package/src/routes/judgment-flow-context.ts +245 -0
  240. package/src/routes/judgments.ts +743 -0
  241. package/src/routes/orgs.ts +44 -27
  242. package/src/routes/policies.ts +19 -0
  243. package/src/routes/projects.ts +106 -95
  244. package/src/routes/provenance.ts +48 -1
  245. package/src/routes/providers.ts +5 -0
  246. package/src/routes/runs.ts +79 -22
  247. package/src/routes/scope-params.ts +35 -1
  248. package/src/routes/teams.ts +96 -90
  249. package/src/types.ts +4 -3
  250. package/src/webhook-endpoint-binding.ts +11 -0
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { Hono } from 'hono';
5
5
 
6
- import type { ConversationBinding } from '@kindgi/agents';
6
+ import { AGENT_GATE_SUBJECTS, TOOL_CALL_GATE_SUBJECT } from '@kindgi/agents';
7
+ import type { ConversationBinding, GateDecisionValue } from '@kindgi/agents';
7
8
  import { REVIEWER_ROLE_RANK, type ReviewerRole } from '@kindgi/authz';
8
9
  import { serializePublicKeyPem, signEd25519 } from '@kindgi/crypto';
9
10
  import type { SigningKeyBinding } from '@kindgi/crypto';
@@ -23,10 +24,18 @@ import type {
23
24
 
24
25
  import { statusFor, toWireError } from '../errors.js';
25
26
  import type { RunHandlerBinding } from '../handler-binding.js';
26
- import type { Approval, ApprovalStatus, HitlBinding, ReviewDecisionKind } from '../hitl-binding.js';
27
+ import type {
28
+ Approval,
29
+ ApprovalStatus,
30
+ HitlBinding,
31
+ ReviewDecisionKind,
32
+ ReviewDecisionRecord,
33
+ } from '../hitl-binding.js';
27
34
  import type { ReviewerBinding } from '../reviewer-binding.js';
35
+ import { callerReviewerRole } from '../reviewer-role.js';
28
36
  import type { AppEnv } from '../types.js';
29
37
  import { clampLimit } from './pagination.js';
38
+ import { parseListScope } from './scope-params.js';
30
39
 
31
40
  const APPROVAL_STATUSES: ReadonlySet<ApprovalStatus> = new Set([
32
41
  'pending',
@@ -80,10 +89,11 @@ export interface ApprovalsRouterOptions {
80
89
  /**
81
90
  * Approvals resource routes.
82
91
  *
83
- * Reviewer-role scoping: routes require `c.get('reviewerRole')` to be
84
- * set by the auth middleware (see `TokenResolution.reviewerRole`).
85
- * Tokens without a reviewer role get `403 permission-denied` on every
86
- * approvals route — this surface is reviewer-only.
92
+ * Reviewer-role scoping: routes require a reviewer role — the token's
93
+ * (`TokenResolution.reviewerRole`), or its user's in the reviewer roster
94
+ * (`ReviewerBinding.resolveReviewerRole`). A caller with neither gets
95
+ * `403 permission-denied` on every approvals route — this surface is
96
+ * reviewer-only.
87
97
  *
88
98
  * Visibility filter: a caller with role `R` sees approvals whose
89
99
  * `requiredRole` rank ≤ their rank (standard < senior < admin). The
@@ -103,9 +113,11 @@ export function approvalsRouter(
103
113
  const runHandler = options.runHandler;
104
114
 
105
115
  // ---------- role gate for the whole resource ----------
116
+ // A reviewer: a token that carries a role, or whose user the roster
117
+ // names (a session or API key of a registered reviewer).
106
118
  r.use('*', async (c, next) => {
107
119
  const requestId = c.get('requestId');
108
- const role = c.get('reviewerRole');
120
+ const role = await callerReviewerRole(c, reviewerBinding);
109
121
  if (role === undefined) {
110
122
  c.status(statusFor('permission-denied') as never);
111
123
  return c.json(
@@ -113,7 +125,7 @@ export function approvalsRouter(
113
125
  {
114
126
  code: 'permission-denied',
115
127
  message:
116
- 'This token was not provisioned with a reviewer role; approvals surface is reviewer-only.',
128
+ "The caller isn't a reviewer: its token carries no reviewer role, and its user isn't registered as one (`kindgi reviewers register`). The approvals surface is reviewer-only.",
117
129
  },
118
130
  requestId,
119
131
  ),
@@ -130,6 +142,14 @@ export function approvalsRouter(
130
142
  const role = c.get('reviewerRole') as ReviewerRole;
131
143
  const limit = clampLimit(c.req.query('limit'));
132
144
 
145
+ const scopeParsed = parseListScope(c.req.query(), { tenantId });
146
+ if (scopeParsed.kind === 'err') {
147
+ c.status(statusFor('scope-invalid') as never);
148
+ return c.json(
149
+ toWireError({ code: 'scope-invalid', message: scopeParsed.message }, requestId),
150
+ );
151
+ }
152
+
133
153
  const statusRaw = c.req.query('status');
134
154
  let statusFilter: ApprovalStatus | undefined;
135
155
  if (statusRaw !== undefined && statusRaw.length > 0) {
@@ -213,6 +233,7 @@ export function approvalsRouter(
213
233
  const listInput = {
214
234
  tenantId,
215
235
  limit: HITL_LIMIT_CAP,
236
+ ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
216
237
  ...(statusFilter !== undefined && { status: statusFilter }),
217
238
  ...(requiredRoleFilter !== undefined && { requiredRole: requiredRoleFilter }),
218
239
  ...(createdAfterIso !== undefined && { since: createdAfterIso as unknown as Timestamp }),
@@ -327,6 +348,22 @@ export function approvalsRouter(
327
348
  );
328
349
  }
329
350
 
351
+ // An agent's tool-call and session gates resume on the decision alone
352
+ // (`readGateDecision` fails closed on anything else), so a `value`
353
+ // can't stand in for it: refused before anything is recorded.
354
+ if (parsed.value.value !== undefined && AGENT_GATE_SUBJECTS.has(approval.subjectKind)) {
355
+ c.status(statusFor('bad-input') as never);
356
+ return c.json(
357
+ toWireError(
358
+ {
359
+ code: 'bad-input',
360
+ message: `An approval of an agent's ${approval.subjectKind === TOOL_CALL_GATE_SUBJECT ? 'tool call' : 'session'} takes a decision (approve or reject) and an optional rationale, not a \`value\`: the turn resumes on the decision alone.`,
361
+ },
362
+ requestId,
363
+ ),
364
+ );
365
+ }
366
+
330
367
  const reviewerId = await reviewerBinding.resolveReviewer({
331
368
  tenantId,
332
369
  userId: userId as UserId,
@@ -362,23 +399,25 @@ export function approvalsRouter(
362
399
  // stays suspended until the new (escalated) approval terminates,
363
400
  // or the run is cancelled explicitly.
364
401
  //
365
- // Resume value shape: `{ decided, rationale? }` — the shape the
366
- // agent's approval gate consumes. The caller CAN supply an explicit
367
- // `value` in the wire body to override it, when the resume payload
368
- // must carry more than the decision.
402
+ // Resume value shape: `GateDecisionValue`, `{ decided, rationale?,
403
+ // decidedBy, approvalId }`, the shape the agent's approval gate
404
+ // consumes; the run's journal keeps who decided which approval. For
405
+ // another subject, the caller CAN supply an explicit `value` to
406
+ // override it, when the resume payload must carry more than the
407
+ // decision (an agent gate refuses one, above).
369
408
  let waitpointResolved = false;
370
409
  if (
371
410
  approval.waitTokenId !== undefined &&
372
411
  approval.provenanceRef?.runId !== undefined &&
373
412
  (parsed.value.decision === 'approve' || parsed.value.decision === 'reject')
374
413
  ) {
375
- const resumeValue =
376
- parsed.value.value !== undefined
377
- ? parsed.value.value
378
- : {
379
- decided: parsed.value.decision as 'approve' | 'reject',
380
- ...(parsed.value.rationale !== undefined && { rationale: parsed.value.rationale }),
381
- };
414
+ const decided: GateDecisionValue = {
415
+ decided: parsed.value.decision,
416
+ ...(parsed.value.rationale !== undefined && { rationale: parsed.value.rationale }),
417
+ decidedBy: `user:${userId as unknown as string}`,
418
+ approvalId: approval.id as unknown as string,
419
+ };
420
+ const resumeValue = parsed.value.value !== undefined ? parsed.value.value : decided;
382
421
  const resolved = await runBinding.completeToken(
383
422
  tenantId,
384
423
  approval.provenanceRef.runId as RunId,
@@ -661,6 +700,18 @@ function serializeApproval(a: Approval): Record<string, unknown> {
661
700
  updatedAt: a.updatedAt,
662
701
  decidedAt: a.decidedAt,
663
702
  expiresAt: a.expiresAt,
703
+ decision: a.decision === undefined ? undefined : serializeApprovalDecision(a.decision),
704
+ };
705
+ }
706
+
707
+ function serializeApprovalDecision(d: ReviewDecisionRecord): Record<string, unknown> {
708
+ return {
709
+ decision: d.decision,
710
+ ...(d.decidedBy !== undefined && { decidedBy: d.decidedBy }),
711
+ reviewerId: d.reviewerId,
712
+ reviewerRoleAtDecision: d.reviewerRoleAtDecision,
713
+ decidedAt: d.decidedAt,
714
+ ...(d.rationale !== undefined && { rationale: d.rationale }),
664
715
  };
665
716
  }
666
717
 
@@ -0,0 +1,362 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type Context, Hono } from 'hono';
5
+
6
+ import {
7
+ BLOCK_KINDS,
8
+ type BlockDefinition,
9
+ type BlockKind,
10
+ settingsSchemaIssues,
11
+ validateBlock,
12
+ } from '@kindgi/agents';
13
+ import { ref } from '@kindgi/authz';
14
+ import type { Cursor, ProjectId, TenantId } from '@kindgi/types';
15
+
16
+ import type { BlockPublishOutcome, BlockRecord, BlockRegistryBinding } from '../block-binding.js';
17
+ import { statusFor, toWireError } from '../errors.js';
18
+ import type { Authorizer } from '../middleware/authorize.js';
19
+ import type { AppEnv } from '../types.js';
20
+ import { clampLimit } from './pagination.js';
21
+ import { parseScopeParams } from './scope-params.js';
22
+
23
+ /**
24
+ * Data blocks: versioned prompts and settings that agent versions pin
25
+ * (`/v1/blocks`).
26
+ *
27
+ * A block belongs to one project and is authorized through it: reading
28
+ * is `read` on the project, publishing, unregistering and reinstating
29
+ * are `write` on it. A block the caller can't read answers 404, as one
30
+ * that doesn't exist.
31
+ *
32
+ * A version never changes, nor does a block's kind. A settings block's
33
+ * values must satisfy the latest version's schema as well as their
34
+ * own, so whatever reads them sees a stable shape.
35
+ */
36
+ export function blocksRouter(binding: BlockRegistryBinding, authorizer?: Authorizer): Hono<AppEnv> {
37
+ const r = new Hono<AppEnv>();
38
+
39
+ async function canProject(
40
+ c: Context<AppEnv>,
41
+ action: 'read' | 'write',
42
+ projectId: ProjectId,
43
+ ): Promise<boolean> {
44
+ return authorizer === undefined || authorizer.can(c, action, ref('project', projectId));
45
+ }
46
+
47
+ function notFound(c: Context<AppEnv>, blockId: string, version?: string) {
48
+ c.status(statusFor('block-not-found') as never);
49
+ return c.json(
50
+ toWireError(
51
+ {
52
+ code: 'block-not-found',
53
+ message: `No block "${blockId}"${version !== undefined ? ` at version "${version}"` : ''}`,
54
+ blockId,
55
+ ...(version !== undefined && { version }),
56
+ },
57
+ c.get('requestId'),
58
+ ),
59
+ );
60
+ }
61
+
62
+ function badInput(c: Context<AppEnv>, message: string) {
63
+ c.status(statusFor('bad-input') as never);
64
+ return c.json(toWireError({ code: 'bad-input', message }, c.get('requestId')));
65
+ }
66
+
67
+ // ---------- GET / (each block's latest version) ----------
68
+ r.get('/', async (c) => {
69
+ const tenantId = c.get('tenantId') as TenantId;
70
+ const kind = c.req.query('kind');
71
+ if (kind !== undefined && kind.length > 0 && !isBlockKind(kind)) {
72
+ return badInput(
73
+ c,
74
+ `Unknown block kind "${kind}". Expected one of: ${BLOCK_KINDS.join(', ')}.`,
75
+ );
76
+ }
77
+ const scope = parseScopeParams(c.req.query(), { tenantId });
78
+ if (scope.kind === 'err') {
79
+ c.status(statusFor('scope-invalid') as never);
80
+ return c.json(
81
+ toWireError({ code: 'scope-invalid', message: scope.message }, c.get('requestId')),
82
+ );
83
+ }
84
+ const cursor = c.req.query('cursor');
85
+ const name = c.req.query('name');
86
+ const page = await binding.list({
87
+ tenantId,
88
+ limit: clampLimit(c.req.query('limit')),
89
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
90
+ ...(kind !== undefined && kind.length > 0 && { blockKind: kind as BlockKind }),
91
+ ...(name !== undefined && name.length > 0 && { nameFilter: name }),
92
+ ...(scope.scope !== undefined && { scope: scope.scope }),
93
+ });
94
+ const visible =
95
+ authorizer === undefined
96
+ ? page.data
97
+ : await authorizer.filterByCan(c, 'read', page.data, (b) => ref('project', b.projectId));
98
+ return c.json({
99
+ data: visible.map(serializeBlock),
100
+ hasMore: page.nextCursor !== undefined,
101
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
102
+ });
103
+ });
104
+
105
+ // ---------- GET /:blockId (latest version) ----------
106
+ r.get('/:blockId', async (c) => {
107
+ const blockId = c.req.param('blockId');
108
+ const block = await binding.get({ tenantId: c.get('tenantId') as TenantId, blockId });
109
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
110
+ return notFound(c, blockId);
111
+ }
112
+ return c.json(serializeBlock(block));
113
+ });
114
+
115
+ // ---------- GET /:blockId/versions ----------
116
+ r.get('/:blockId/versions', async (c) => {
117
+ const tenantId = c.get('tenantId') as TenantId;
118
+ const blockId = c.req.param('blockId');
119
+ const cursor = c.req.query('cursor');
120
+ const page = await binding.listVersions({
121
+ tenantId,
122
+ blockId,
123
+ limit: clampLimit(c.req.query('limit')),
124
+ includeTombstoned: c.req.query('includeTombstoned') === 'true',
125
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
126
+ });
127
+ const owner = page.data[0] ?? (await anyVersion(tenantId, blockId));
128
+ if (owner === null || !(await canProject(c, 'read', owner.projectId))) {
129
+ return notFound(c, blockId);
130
+ }
131
+ return c.json({
132
+ data: page.data.map(serializeBlock),
133
+ hasMore: page.nextCursor !== undefined,
134
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
135
+ });
136
+ });
137
+
138
+ // ---------- GET /:blockId/versions/:version ----------
139
+ r.get('/:blockId/versions/:version', async (c) => {
140
+ const blockId = c.req.param('blockId');
141
+ const version = c.req.param('version');
142
+ const block = await binding.getVersion({
143
+ tenantId: c.get('tenantId') as TenantId,
144
+ blockId,
145
+ version,
146
+ });
147
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
148
+ return notFound(c, blockId, version);
149
+ }
150
+ return c.json(serializeBlock(block));
151
+ });
152
+
153
+ // ---------- POST / (publish a version) ----------
154
+ r.post('/', async (c) => {
155
+ const requestId = c.get('requestId');
156
+ const tenantId = c.get('tenantId') as TenantId;
157
+ let body: unknown;
158
+ try {
159
+ body = await c.req.json();
160
+ } catch {
161
+ return badInput(c, 'Request body must be valid JSON');
162
+ }
163
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
164
+ return badInput(c, 'Request body must be an object');
165
+ }
166
+ const { projectId, ...definition } = body as Record<string, unknown>;
167
+ if (typeof projectId !== 'string' || projectId.length === 0) {
168
+ return badInput(c, '`projectId` is required');
169
+ }
170
+ if (!(await canProject(c, 'write', projectId as ProjectId))) {
171
+ c.status(statusFor('permission-denied') as never);
172
+ return c.json(
173
+ toWireError(
174
+ {
175
+ code: 'permission-denied',
176
+ message: `Publishing a block needs write on project "${projectId}"`,
177
+ },
178
+ requestId,
179
+ ),
180
+ );
181
+ }
182
+ const validated = validateBlock(definition);
183
+ if (validated.kind === 'err')
184
+ return validationFailed(c, validated.error.message, validated.error.issues);
185
+ const block = validated.value;
186
+
187
+ // A block keeps its kind, and a settings block's latest schema holds.
188
+ const latest = await binding.get({ tenantId, blockId: block.id });
189
+ const continuity = continuityIssues(latest, block);
190
+ if (continuity.length > 0) {
191
+ return validationFailed(c, `Block "${block.id}" can't take this version`, continuity);
192
+ }
193
+
194
+ const outcome = await binding.publish({ tenantId, projectId: projectId as ProjectId, block });
195
+ return published(c, outcome);
196
+ });
197
+
198
+ // ---------- POST /:blockId/versions/:version/unregister ----------
199
+ r.post('/:blockId/versions/:version/unregister', async (c) => {
200
+ const tenantId = c.get('tenantId') as TenantId;
201
+ const blockId = c.req.param('blockId');
202
+ const version = c.req.param('version');
203
+ const block = await binding.getVersion({ tenantId, blockId, version });
204
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
205
+ return notFound(c, blockId, version);
206
+ }
207
+ if (!(await canProject(c, 'write', block.projectId))) return denied(c, block.projectId);
208
+ const outcome = await binding.unregister({ tenantId, blockId, version });
209
+ if (!outcome.unregistered) return notFound(c, blockId, version);
210
+ return c.json({ blockId, version, unregistered: true });
211
+ });
212
+
213
+ // ---------- POST /:blockId/versions/:version/reinstate ----------
214
+ r.post('/:blockId/versions/:version/reinstate', async (c) => {
215
+ const tenantId = c.get('tenantId') as TenantId;
216
+ const blockId = c.req.param('blockId');
217
+ const version = c.req.param('version');
218
+ const block = await binding.getVersion({ tenantId, blockId, version });
219
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
220
+ return notFound(c, blockId, version);
221
+ }
222
+ if (!(await canProject(c, 'write', block.projectId))) return denied(c, block.projectId);
223
+ const outcome = await binding.reinstateVersion({ tenantId, blockId, version });
224
+ if (outcome.kind === 'not-found') return notFound(c, blockId, version);
225
+ return c.json({ blockId, version, wasTombstoned: outcome.wasTombstoned });
226
+ });
227
+
228
+ /** Any version of a block, unregistered ones included: whose project it is. */
229
+ async function anyVersion(tenantId: TenantId, blockId: string): Promise<BlockRecord | null> {
230
+ const page = await binding.listVersions({
231
+ tenantId,
232
+ blockId,
233
+ limit: 1,
234
+ includeTombstoned: true,
235
+ });
236
+ return page.data[0] ?? null;
237
+ }
238
+
239
+ return r;
240
+ }
241
+
242
+ function isBlockKind(value: string): value is BlockKind {
243
+ return (BLOCK_KINDS as readonly string[]).includes(value);
244
+ }
245
+
246
+ /** A new version against the block's latest: same kind, and a settings block's schema holds. */
247
+ function continuityIssues(
248
+ latest: BlockRecord | null,
249
+ block: BlockDefinition,
250
+ ): { path: string; message: string }[] {
251
+ if (latest === null) return [];
252
+ if (latest.kind !== block.kind) {
253
+ return [
254
+ {
255
+ path: '/kind',
256
+ message: `block "${block.id}" is a ${latest.kind} block; a version can't change its kind`,
257
+ },
258
+ ];
259
+ }
260
+ if (
261
+ latest.kind === 'settings' &&
262
+ block.kind === 'settings' &&
263
+ latest.content.schema !== undefined
264
+ ) {
265
+ return settingsSchemaIssues(block.content.values, latest.content.schema).map((i) => ({
266
+ ...i,
267
+ message: `${i.message} (the schema of version ${latest.version})`,
268
+ }));
269
+ }
270
+ return [];
271
+ }
272
+
273
+ /** The response to a publish outcome. */
274
+ function published(c: Context<AppEnv>, outcome: BlockPublishOutcome) {
275
+ const requestId = c.get('requestId');
276
+ switch (outcome.kind) {
277
+ case 'ok':
278
+ c.status(201);
279
+ return c.json({ blockId: outcome.blockId, version: outcome.version });
280
+ case 'already-registered':
281
+ c.status(statusFor('block-already-registered') as never);
282
+ return c.json(
283
+ toWireError(
284
+ {
285
+ code: 'block-already-registered',
286
+ message: `Block "${outcome.blockId}" version "${outcome.version}" is already published; versions never change, so publish a new one`,
287
+ blockId: outcome.blockId,
288
+ version: outcome.version,
289
+ },
290
+ requestId,
291
+ ),
292
+ );
293
+ case 'project-mismatch':
294
+ c.status(statusFor('block-project-mismatch') as never);
295
+ return c.json(
296
+ toWireError(
297
+ {
298
+ code: 'block-project-mismatch',
299
+ message: `Block "${outcome.blockId}" belongs to project "${outcome.projectId as unknown as string}"; publish its versions there`,
300
+ blockId: outcome.blockId,
301
+ projectId: outcome.projectId as unknown as string,
302
+ },
303
+ requestId,
304
+ ),
305
+ );
306
+ case 'project-not-found':
307
+ c.status(statusFor('bad-input') as never);
308
+ return c.json(
309
+ toWireError(
310
+ {
311
+ code: 'bad-input',
312
+ message: `\`projectId\` "${outcome.projectId as unknown as string}" does not resolve to a project in this tenant`,
313
+ },
314
+ requestId,
315
+ ),
316
+ );
317
+ }
318
+ }
319
+
320
+ function validationFailed(
321
+ c: Context<AppEnv>,
322
+ message: string,
323
+ issues: readonly { readonly path: string; readonly message: string }[],
324
+ ) {
325
+ c.status(statusFor('validation-failed') as never);
326
+ return c.json(
327
+ toWireError(
328
+ {
329
+ code: 'validation-failed',
330
+ message,
331
+ issues: issues as unknown as Record<string, unknown>[],
332
+ },
333
+ c.get('requestId'),
334
+ ),
335
+ );
336
+ }
337
+
338
+ function denied(c: Context<AppEnv>, projectId: ProjectId) {
339
+ c.status(statusFor('permission-denied') as never);
340
+ return c.json(
341
+ toWireError(
342
+ {
343
+ code: 'permission-denied',
344
+ message: `This needs write on the block's project "${projectId as unknown as string}"`,
345
+ },
346
+ c.get('requestId'),
347
+ ),
348
+ );
349
+ }
350
+
351
+ function serializeBlock(b: BlockRecord): Record<string, unknown> {
352
+ return {
353
+ id: b.id,
354
+ version: b.version,
355
+ kind: b.kind,
356
+ ...(b.description !== undefined && { description: b.description }),
357
+ content: b.content,
358
+ projectId: b.projectId as unknown as string,
359
+ publishedAt: b.publishedAt,
360
+ ...(b.unregisteredAt !== undefined && { unregisteredAt: b.unregisteredAt }),
361
+ };
362
+ }
@@ -10,12 +10,16 @@ import type {
10
10
  ConversationMessage,
11
11
  ConversationPageCursor,
12
12
  } from '@kindgi/agents';
13
+ import type { ProjectBinding } from '@kindgi/platform';
13
14
  import type { RunBinding } from '@kindgi/runtime';
14
- import type { AgentId, Semver, TenantId } from '@kindgi/types';
15
+ import type { AgentId, ProjectId, Semver, TenantId } from '@kindgi/types';
15
16
 
16
17
  import { statusFor, toWireError } from '../errors.js';
17
18
  import type { AppEnv } from '../types.js';
18
19
  import { clampLimit, decodeCursor, encodeCursor } from './pagination.js';
20
+ import { parseListScope } from './scope-params.js';
21
+
22
+ const PROJECT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
23
 
20
24
  /**
21
25
  * Conversations resource routes. All storage access goes through the
@@ -27,6 +31,11 @@ import { clampLimit, decodeCursor, encodeCursor } from './pagination.js';
27
31
  export function conversationsRouter(
28
32
  conversationBinding: ConversationBinding,
29
33
  runBinding: RunBinding,
34
+ /**
35
+ * When wired, a `projectId` given to `POST /` must be one of the
36
+ * tenant's projects, and an omitted one is the tenant's Default project.
37
+ */
38
+ projectBinding?: ProjectBinding,
30
39
  ): Hono<AppEnv> {
31
40
  const r = new Hono<AppEnv>();
32
41
 
@@ -53,6 +62,14 @@ export function conversationsRouter(
53
62
 
54
63
  const agentIdRaw = c.req.query('agentId');
55
64
 
65
+ const scopeParsed = parseListScope(c.req.query(), { tenantId });
66
+ if (scopeParsed.kind === 'err') {
67
+ c.status(statusFor('scope-invalid') as never);
68
+ return c.json(
69
+ toWireError({ code: 'scope-invalid', message: scopeParsed.message }, requestId),
70
+ );
71
+ }
72
+
56
73
  let before: ConversationPageCursor | undefined;
57
74
  const rawCursor = c.req.query('cursor');
58
75
  if (rawCursor !== undefined && rawCursor.length > 0) {
@@ -68,6 +85,7 @@ export function conversationsRouter(
68
85
 
69
86
  const listResult = await conversationBinding.listConversationsPage({
70
87
  tenantId,
88
+ ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
71
89
  ...(agentIdRaw !== undefined && agentIdRaw.length > 0 && { agentId: agentIdRaw as AgentId }),
72
90
  ...(statusFilter !== undefined && { status: statusFilter }),
73
91
  ...(before !== undefined && { before }),
@@ -126,9 +144,30 @@ export function conversationsRouter(
126
144
  c.status(statusFor(parsed.error.code) as never);
127
145
  return c.json(toWireError(parsed.error, requestId));
128
146
  }
147
+ const supplied = parsed.value.projectId;
148
+ if (
149
+ supplied !== undefined &&
150
+ projectBinding !== undefined &&
151
+ (await projectBinding.get(tenantId, supplied)) === undefined
152
+ ) {
153
+ c.status(statusFor('bad-input') as never);
154
+ return c.json(
155
+ toWireError(
156
+ {
157
+ code: 'bad-input',
158
+ message: `\`projectId\` "${supplied as unknown as string}" does not resolve to a project in this tenant`,
159
+ },
160
+ requestId,
161
+ ),
162
+ );
163
+ }
164
+ // Omitted: the tenant's Default project, as for a run. No Default (or
165
+ // no project binding): no project, never a refusal.
166
+ const projectId = supplied ?? (await projectBinding?.getDefault(tenantId))?.id;
129
167
 
130
168
  const opened = await conversationBinding.openConversation({
131
169
  tenantId,
170
+ ...(projectId !== undefined && { projectId }),
132
171
  agentId: parsed.value.agentId,
133
172
  agentVersion: parsed.value.agentVersion,
134
173
  title: parsed.value.title,
@@ -232,6 +271,7 @@ function serializeConversation(c: Conversation): Record<string, unknown> {
232
271
  agentVersion: c.agentVersion as unknown as string,
233
272
  title: c.title,
234
273
  ...(c.participantId !== undefined && { participantId: c.participantId }),
274
+ ...(c.projectId !== undefined && { projectId: c.projectId as unknown as string }),
235
275
  scope: c.scope,
236
276
  status: (c.closedAt === undefined ? 'open' : 'closed') as 'open' | 'closed',
237
277
  openedAt: c.openedAt as unknown as string,
@@ -283,6 +323,7 @@ interface ParsedOpenBody {
283
323
  readonly agentVersion: Semver;
284
324
  readonly title: string;
285
325
  readonly scope: Readonly<Record<string, unknown>>;
326
+ readonly projectId?: ProjectId;
286
327
  readonly participantId?: string;
287
328
  readonly metadata?: Readonly<Record<string, unknown>>;
288
329
  }
@@ -335,6 +376,20 @@ function parseOpenBody(
335
376
  scope = scopeRaw as Readonly<Record<string, unknown>>;
336
377
  }
337
378
 
379
+ const projectIdRaw = b.projectId;
380
+ if (
381
+ projectIdRaw !== undefined &&
382
+ (typeof projectIdRaw !== 'string' || !PROJECT_ID_RE.test(projectIdRaw))
383
+ ) {
384
+ return {
385
+ kind: 'err',
386
+ error: {
387
+ code: 'bad-input',
388
+ message: '`projectId` must be a project id (a UUID) when supplied',
389
+ },
390
+ };
391
+ }
392
+
338
393
  const participantIdRaw = b.participantId;
339
394
  if (participantIdRaw !== undefined && typeof participantIdRaw !== 'string') {
340
395
  return {
@@ -362,6 +417,7 @@ function parseOpenBody(
362
417
  agentVersion: agentVersion as Semver,
363
418
  title,
364
419
  scope,
420
+ ...(projectIdRaw !== undefined && { projectId: projectIdRaw as ProjectId }),
365
421
  ...(participantIdRaw !== undefined && { participantId: participantIdRaw }),
366
422
  ...(metadata !== undefined && { metadata }),
367
423
  },