@kindgi/api 0.1.4-rc.1 → 0.1.4-rc.3

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 (220) hide show
  1. package/dist/agent-binding.d.ts +12 -2
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts.map +1 -1
  4. package/dist/agent-pins.js +4 -1
  5. package/dist/agent-pins.js.map +1 -1
  6. package/dist/app.d.ts +8 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +23 -4
  9. package/dist/app.js.map +1 -1
  10. package/dist/deploy-versions.d.ts +8 -5
  11. package/dist/deploy-versions.d.ts.map +1 -1
  12. package/dist/deploy-versions.js +3 -9
  13. package/dist/deploy-versions.js.map +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +25 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/eval-case-binding.d.ts +10 -0
  18. package/dist/eval-case-binding.d.ts.map +1 -1
  19. package/dist/eval-run-binding.d.ts +14 -3
  20. package/dist/eval-run-binding.d.ts.map +1 -1
  21. package/dist/eval-run-binding.js.map +1 -1
  22. package/dist/flow-pins.d.ts +20 -7
  23. package/dist/flow-pins.d.ts.map +1 -1
  24. package/dist/flow-pins.js +36 -11
  25. package/dist/flow-pins.js.map +1 -1
  26. package/dist/gate-policy-binding.d.ts +164 -0
  27. package/dist/gate-policy-binding.d.ts.map +1 -0
  28. package/dist/gate-policy-binding.js +12 -0
  29. package/dist/gate-policy-binding.js.map +1 -0
  30. package/dist/gate.d.ts +56 -0
  31. package/dist/gate.d.ts.map +1 -0
  32. package/dist/gate.js +359 -0
  33. package/dist/gate.js.map +1 -0
  34. package/dist/handler-binding.d.ts +14 -2
  35. package/dist/handler-binding.d.ts.map +1 -1
  36. package/dist/hitl-binding.d.ts +5 -0
  37. package/dist/hitl-binding.d.ts.map +1 -1
  38. package/dist/index.d.ts +10 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/judged-dispatcher.d.ts +16 -1
  43. package/dist/judged-dispatcher.d.ts.map +1 -1
  44. package/dist/judged-dispatcher.js +45 -6
  45. package/dist/judged-dispatcher.js.map +1 -1
  46. package/dist/judgment-binding.d.ts +38 -0
  47. package/dist/judgment-binding.d.ts.map +1 -1
  48. package/dist/judgment-binding.js +19 -0
  49. package/dist/judgment-binding.js.map +1 -1
  50. package/dist/live-version-binding.d.ts +206 -0
  51. package/dist/live-version-binding.d.ts.map +1 -0
  52. package/dist/live-version-binding.js +4 -0
  53. package/dist/live-version-binding.js.map +1 -0
  54. package/dist/middleware/authorize.d.ts.map +1 -1
  55. package/dist/middleware/authorize.js +13 -2
  56. package/dist/middleware/authorize.js.map +1 -1
  57. package/dist/middleware/project-ref.d.ts +25 -0
  58. package/dist/middleware/project-ref.d.ts.map +1 -0
  59. package/dist/middleware/project-ref.js +72 -0
  60. package/dist/middleware/project-ref.js.map +1 -0
  61. package/dist/openapi/generate.d.ts.map +1 -1
  62. package/dist/openapi/generate.js +4 -0
  63. package/dist/openapi/generate.js.map +1 -1
  64. package/dist/openapi/operations.d.ts +6 -0
  65. package/dist/openapi/operations.d.ts.map +1 -1
  66. package/dist/openapi/operations.js +512 -22
  67. package/dist/openapi/operations.js.map +1 -1
  68. package/dist/openapi/schemas.d.ts +37 -0
  69. package/dist/openapi/schemas.d.ts.map +1 -1
  70. package/dist/openapi/schemas.js +709 -5
  71. package/dist/openapi/schemas.js.map +1 -1
  72. package/dist/publish-refused.d.ts +18 -0
  73. package/dist/publish-refused.d.ts.map +1 -0
  74. package/dist/publish-refused.js +22 -0
  75. package/dist/publish-refused.js.map +1 -0
  76. package/dist/retention-binding.d.ts +29 -0
  77. package/dist/retention-binding.d.ts.map +1 -1
  78. package/dist/routes/agent-releases.d.ts +40 -0
  79. package/dist/routes/agent-releases.d.ts.map +1 -0
  80. package/dist/routes/agent-releases.js +462 -0
  81. package/dist/routes/agent-releases.js.map +1 -0
  82. package/dist/routes/agents.d.ts +7 -1
  83. package/dist/routes/agents.d.ts.map +1 -1
  84. package/dist/routes/agents.js +31 -3
  85. package/dist/routes/agents.js.map +1 -1
  86. package/dist/routes/approvals.d.ts.map +1 -1
  87. package/dist/routes/approvals.js +40 -9
  88. package/dist/routes/approvals.js.map +1 -1
  89. package/dist/routes/audit.d.ts.map +1 -1
  90. package/dist/routes/audit.js +7 -0
  91. package/dist/routes/audit.js.map +1 -1
  92. package/dist/routes/auth.js +1 -1
  93. package/dist/routes/auth.js.map +1 -1
  94. package/dist/routes/conversations.d.ts +0 -7
  95. package/dist/routes/conversations.d.ts.map +1 -1
  96. package/dist/routes/conversations.js +19 -3
  97. package/dist/routes/conversations.js.map +1 -1
  98. package/dist/routes/deployments.d.ts +6 -0
  99. package/dist/routes/deployments.d.ts.map +1 -1
  100. package/dist/routes/deployments.js +33 -5
  101. package/dist/routes/deployments.js.map +1 -1
  102. package/dist/routes/env.d.ts.map +1 -1
  103. package/dist/routes/env.js +1 -0
  104. package/dist/routes/env.js.map +1 -1
  105. package/dist/routes/eval-comparison.d.ts +2 -1
  106. package/dist/routes/eval-comparison.d.ts.map +1 -1
  107. package/dist/routes/eval-comparison.js +22 -9
  108. package/dist/routes/eval-comparison.js.map +1 -1
  109. package/dist/routes/flows.d.ts +14 -7
  110. package/dist/routes/flows.d.ts.map +1 -1
  111. package/dist/routes/flows.js +10 -8
  112. package/dist/routes/flows.js.map +1 -1
  113. package/dist/routes/gate-policies.d.ts +19 -0
  114. package/dist/routes/gate-policies.d.ts.map +1 -0
  115. package/dist/routes/gate-policies.js +191 -0
  116. package/dist/routes/gate-policies.js.map +1 -0
  117. package/dist/routes/gate-policy-spec.d.ts +17 -0
  118. package/dist/routes/gate-policy-spec.d.ts.map +1 -0
  119. package/dist/routes/gate-policy-spec.js +192 -0
  120. package/dist/routes/gate-policy-spec.js.map +1 -0
  121. package/dist/routes/gate-policy-wire.d.ts +3 -0
  122. package/dist/routes/gate-policy-wire.d.ts.map +1 -0
  123. package/dist/routes/gate-policy-wire.js +18 -0
  124. package/dist/routes/gate-policy-wire.js.map +1 -0
  125. package/dist/routes/hierarchy-errors.d.ts +11 -0
  126. package/dist/routes/hierarchy-errors.d.ts.map +1 -1
  127. package/dist/routes/hierarchy-errors.js +13 -0
  128. package/dist/routes/hierarchy-errors.js.map +1 -1
  129. package/dist/routes/judged-suites.js +7 -0
  130. package/dist/routes/judged-suites.js.map +1 -1
  131. package/dist/routes/judgments.d.ts +4 -1
  132. package/dist/routes/judgments.d.ts.map +1 -1
  133. package/dist/routes/judgments.js +92 -19
  134. package/dist/routes/judgments.js.map +1 -1
  135. package/dist/routes/live-scope-wire.d.ts +32 -0
  136. package/dist/routes/live-scope-wire.d.ts.map +1 -0
  137. package/dist/routes/live-scope-wire.js +71 -0
  138. package/dist/routes/live-scope-wire.js.map +1 -0
  139. package/dist/routes/policies.d.ts +6 -3
  140. package/dist/routes/policies.d.ts.map +1 -1
  141. package/dist/routes/policies.js +55 -3
  142. package/dist/routes/policies.js.map +1 -1
  143. package/dist/routes/projects.d.ts.map +1 -1
  144. package/dist/routes/projects.js +47 -3
  145. package/dist/routes/projects.js.map +1 -1
  146. package/dist/routes/retention.d.ts.map +1 -1
  147. package/dist/routes/retention.js +18 -2
  148. package/dist/routes/retention.js.map +1 -1
  149. package/dist/routes/reviewers.d.ts +8 -1
  150. package/dist/routes/reviewers.d.ts.map +1 -1
  151. package/dist/routes/reviewers.js +20 -3
  152. package/dist/routes/reviewers.js.map +1 -1
  153. package/dist/routes/runs.d.ts.map +1 -1
  154. package/dist/routes/runs.js +22 -13
  155. package/dist/routes/runs.js.map +1 -1
  156. package/dist/routes/secrets.d.ts.map +1 -1
  157. package/dist/routes/secrets.js +2 -0
  158. package/dist/routes/secrets.js.map +1 -1
  159. package/dist/routes/segments.d.ts +17 -0
  160. package/dist/routes/segments.d.ts.map +1 -0
  161. package/dist/routes/segments.js +68 -0
  162. package/dist/routes/segments.js.map +1 -0
  163. package/dist/routes/teams.d.ts.map +1 -1
  164. package/dist/routes/teams.js +42 -3
  165. package/dist/routes/teams.js.map +1 -1
  166. package/dist/routes/uuid-param.d.ts +11 -0
  167. package/dist/routes/uuid-param.d.ts.map +1 -0
  168. package/dist/routes/uuid-param.js +19 -0
  169. package/dist/routes/uuid-param.js.map +1 -0
  170. package/openapi.json +7008 -3624
  171. package/package.json +21 -21
  172. package/src/agent-binding.ts +12 -2
  173. package/src/agent-pins.ts +3 -1
  174. package/src/app.ts +37 -3
  175. package/src/deploy-versions.ts +11 -14
  176. package/src/errors.ts +25 -0
  177. package/src/eval-case-binding.ts +7 -0
  178. package/src/eval-run-binding.ts +24 -3
  179. package/src/flow-pins.ts +49 -9
  180. package/src/gate-policy-binding.ts +171 -0
  181. package/src/gate.ts +467 -0
  182. package/src/handler-binding.ts +14 -2
  183. package/src/hitl-binding.ts +5 -0
  184. package/src/index.ts +46 -1
  185. package/src/judged-dispatcher.ts +74 -6
  186. package/src/judgment-binding.ts +62 -0
  187. package/src/live-version-binding.ts +237 -0
  188. package/src/middleware/authorize.ts +18 -2
  189. package/src/middleware/project-ref.ts +88 -0
  190. package/src/openapi/generate.ts +5 -0
  191. package/src/openapi/operations.ts +619 -22
  192. package/src/openapi/schemas.ts +788 -5
  193. package/src/publish-refused.ts +31 -0
  194. package/src/retention-binding.ts +30 -0
  195. package/src/routes/agent-releases.ts +581 -0
  196. package/src/routes/agents.ts +43 -2
  197. package/src/routes/approvals.ts +58 -8
  198. package/src/routes/audit.ts +10 -0
  199. package/src/routes/auth.ts +1 -1
  200. package/src/routes/conversations.ts +31 -3
  201. package/src/routes/deployments.ts +50 -5
  202. package/src/routes/env.ts +1 -0
  203. package/src/routes/eval-comparison.ts +24 -10
  204. package/src/routes/flows.ts +27 -11
  205. package/src/routes/gate-policies.ts +226 -0
  206. package/src/routes/gate-policy-spec.ts +229 -0
  207. package/src/routes/gate-policy-wire.ts +20 -0
  208. package/src/routes/hierarchy-errors.ts +14 -0
  209. package/src/routes/judged-suites.ts +5 -0
  210. package/src/routes/judgments.ts +107 -23
  211. package/src/routes/live-scope-wire.ts +90 -0
  212. package/src/routes/policies.ts +61 -3
  213. package/src/routes/projects.ts +54 -2
  214. package/src/routes/retention.ts +21 -2
  215. package/src/routes/reviewers.ts +23 -4
  216. package/src/routes/runs.ts +33 -20
  217. package/src/routes/secrets.ts +2 -0
  218. package/src/routes/segments.ts +75 -0
  219. package/src/routes/teams.ts +53 -3
  220. package/src/routes/uuid-param.ts +28 -0
@@ -2,11 +2,18 @@
2
2
  // Copyright (C) 2026 Kindgi Inc.
3
3
 
4
4
  import { Hono } from 'hono';
5
- import type { MiddlewareHandler } from 'hono';
6
5
 
7
6
  import type { AgentId } from '@kindgi/agents';
8
7
  import type { KernelRunRecord, ListRunsInput, RunBinding } from '@kindgi/runtime';
9
- import type { FlowId, ListScope, ProjectId, RunId, Semver, TenantId } from '@kindgi/types';
8
+ import type {
9
+ FlowId,
10
+ ListScope,
11
+ ProjectId,
12
+ RunId,
13
+ ScopeSegment,
14
+ Semver,
15
+ TenantId,
16
+ } from '@kindgi/types';
10
17
 
11
18
  import { ref } from '@kindgi/authz';
12
19
 
@@ -20,9 +27,11 @@ import type {
20
27
  import type { Authorizer } from '../middleware/authorize.js';
21
28
  import type { MintPublicRunTokenResult } from '../public-run-token.js';
22
29
  import type { AppEnv } from '../types.js';
30
+ import { liveScopeToWire } from './live-scope-wire.js';
23
31
  import type { DecodedCursor } from './pagination.js';
24
32
  import { clampLimit, decodeCursor } from './pagination.js';
25
33
  import { parseListScope } from './scope-params.js';
34
+ import { parseSegmentsBody } from './segments.js';
26
35
  import {
27
36
  formatSseFrame,
28
37
  isTerminalWireKind,
@@ -30,6 +39,7 @@ import {
30
39
  projectJournalEntry,
31
40
  toRunProgressEvent,
32
41
  } from './sse.js';
42
+ import { UUID_RE, refuseMalformedUuidParam } from './uuid-param.js';
33
43
 
34
44
  const KERNEL_RUN_CHANNEL_PREFIX = 'kernel:run:';
35
45
 
@@ -61,23 +71,8 @@ const MAX_RUN_ANCESTRY = 16;
61
71
  * `POST /:runId/cancel`, `POST /:runId/resume`, `GET /:runId/stream`
62
72
  * (SSE), `GET /:runId/journal`.
63
73
  */
64
- /** A run id: a UUID. */
65
- const RUN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
66
-
67
- /**
68
- * A `:runId` that isn't a run id is a 400, before it reaches a query (where
69
- * Postgres's uuid cast would fail it as a 500).
70
- */
71
- const refuseMalformedRunId: MiddlewareHandler<AppEnv> = async (c, next) => {
72
- if (RUN_ID_RE.test(c.req.param('runId') ?? '')) return next();
73
- c.status(statusFor('bad-input') as never);
74
- return c.json(
75
- toWireError(
76
- { code: 'bad-input', message: '`runId` must be a run id (a UUID)' },
77
- c.get('requestId'),
78
- ),
79
- );
80
- };
74
+ /** A `:runId` that isn't a run id is a 400 (`uuid-param.ts`). */
75
+ const refuseMalformedRunId = refuseMalformedUuidParam('runId', 'a run id');
81
76
 
82
77
  export function runsRouter(
83
78
  binding: RunHandlerBinding,
@@ -634,6 +629,7 @@ function invokeFromBody(
634
629
  tenantId,
635
630
  ...(body.projectId !== undefined && { projectId: body.projectId }),
636
631
  input: body.input,
632
+ ...(body.segments !== undefined && { segments: body.segments }),
637
633
  ...(body.dryRun !== undefined && { dryRun: body.dryRun }),
638
634
  ...(body.wait !== undefined && { wait: body.wait }),
639
635
  };
@@ -681,11 +677,19 @@ function serializeRun(
681
677
  id: row.agent.id,
682
678
  version: row.agent.version,
683
679
  conversationId: row.agent.conversationId as unknown as string,
680
+ ...(row.agent.via !== undefined && { via: row.agent.via }),
681
+ ...(row.agent.liveScope !== undefined && {
682
+ liveScope: liveScopeToWire(row.agent.liveScope),
683
+ }),
684
684
  },
685
685
  }),
686
686
  ...(row.replayOf != null && { replayOf: row.replayOf as unknown as string }),
687
687
  ...(row.evalRunId != null && { evalRunId: row.evalRunId }),
688
688
  ...(row.versions != null && { versions: row.versions }),
689
+ ...(row.segments !== undefined &&
690
+ row.segments.length > 0 && {
691
+ segments: row.segments.map(({ key, value }) => ({ key, value })),
692
+ }),
689
693
  };
690
694
  }
691
695
 
@@ -777,7 +781,7 @@ function parseRunListFilter(
777
781
  return { kind: 'err', message: '`topLevel` must be `true` or `false`' };
778
782
  }
779
783
  const topLevelOnly = topLevel === 'true';
780
- if (parentRunId !== undefined && !RUN_ID_RE.test(parentRunId)) {
784
+ if (parentRunId !== undefined && !UUID_RE.test(parentRunId)) {
781
785
  return { kind: 'err', message: '`parentRunId` must be a run id (a UUID)' };
782
786
  }
783
787
  if (parentRunId !== undefined && topLevelOnly) {
@@ -849,6 +853,7 @@ type ParsedStartRunBody =
849
853
  readonly agentId: AgentId;
850
854
  readonly agentVersion?: Semver;
851
855
  readonly projectId?: ProjectId;
856
+ readonly segments?: readonly ScopeSegment[];
852
857
  readonly input: unknown;
853
858
  readonly dryRun?: boolean;
854
859
  readonly wait?: boolean;
@@ -858,6 +863,7 @@ type ParsedStartRunBody =
858
863
  readonly flowId: FlowId;
859
864
  readonly flowVersion?: Semver;
860
865
  readonly projectId?: ProjectId;
866
+ readonly segments?: readonly ScopeSegment[];
861
867
  readonly input: unknown;
862
868
  readonly dryRun?: boolean;
863
869
  readonly wait?: boolean;
@@ -893,6 +899,11 @@ function parseStartRunBody(
893
899
  const options = parseStartOptions(b.options);
894
900
  if (options.kind === 'err') return options;
895
901
  const { dryRun, wait } = options.value;
902
+ const parsedSegments = parseSegmentsBody(b.segments);
903
+ if (parsedSegments.kind === 'err') {
904
+ return { kind: 'err', error: { code: 'bad-input', message: parsedSegments.message } };
905
+ }
906
+ const { segments } = parsedSegments;
896
907
  if (hasAgent) {
897
908
  const agentVersion = b.agentVersion;
898
909
  if (agentVersion !== undefined && typeof agentVersion !== 'string') {
@@ -915,6 +926,7 @@ function parseStartRunBody(
915
926
  agentId: b.agent as AgentId,
916
927
  ...(agentVersion !== undefined && { agentVersion: agentVersion as Semver }),
917
928
  ...(agentProjectIdRaw !== undefined && { projectId: agentProjectIdRaw as ProjectId }),
929
+ ...(segments !== undefined && { segments }),
918
930
  input: b.input,
919
931
  ...(dryRun !== undefined && { dryRun }),
920
932
  ...(wait !== undefined && { wait }),
@@ -942,6 +954,7 @@ function parseStartRunBody(
942
954
  flowId: b.flow as FlowId,
943
955
  ...(flowVersion !== undefined && { flowVersion: flowVersion as Semver }),
944
956
  ...(projectId !== undefined && { projectId: projectId as ProjectId }),
957
+ ...(segments !== undefined && { segments }),
945
958
  input: b.input,
946
959
  ...(dryRun !== undefined && { dryRun }),
947
960
  ...(wait !== undefined && { wait }),
@@ -160,6 +160,7 @@ export function secretsRouter(options: SecretsRouterOptions): Hono<AppEnv> {
160
160
  });
161
161
  return c.json({
162
162
  data: page.data.map(serializeSecretRecord),
163
+ hasMore: page.nextCursor !== undefined,
163
164
  ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
164
165
  });
165
166
  });
@@ -232,6 +233,7 @@ export function secretsRouter(options: SecretsRouterOptions): Hono<AppEnv> {
232
233
  });
233
234
  return c.json({
234
235
  data: page.data.map(serializeVersion),
236
+ hasMore: page.nextCursor !== undefined,
235
237
  ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
236
238
  });
237
239
  });
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { ScopeSegment } from '@kindgi/types';
5
+
6
+ /** A segment key: lowercase, like an identifier (`company`, `contact-role`). */
7
+ const SEGMENT_KEY_RE = /^[a-z][a-z0-9_-]{0,63}$/;
8
+ const MAX_SEGMENTS = 8;
9
+ const MAX_VALUE_LENGTH = 256;
10
+
11
+ type Parsed =
12
+ | { readonly kind: 'ok'; readonly segments?: readonly ScopeSegment[] }
13
+ | { readonly kind: 'err'; readonly message: string };
14
+
15
+ function checked(segments: readonly ScopeSegment[], field: string): Parsed {
16
+ if (segments.length === 0) return { kind: 'ok' };
17
+ if (segments.length > MAX_SEGMENTS) {
18
+ return { kind: 'err', message: `\`${field}\` takes at most ${MAX_SEGMENTS} segments` };
19
+ }
20
+ const seen = new Set<string>();
21
+ for (const { key, value } of segments) {
22
+ if (!SEGMENT_KEY_RE.test(key)) {
23
+ return {
24
+ kind: 'err',
25
+ message: `\`${field}\`: a segment key is lowercase letters, digits, - and _ (starting with a letter); got "${key}"`,
26
+ };
27
+ }
28
+ if (seen.has(key))
29
+ return { kind: 'err', message: `\`${field}\`: the key "${key}" appears twice` };
30
+ seen.add(key);
31
+ if (value.length === 0 || value.length > MAX_VALUE_LENGTH) {
32
+ return {
33
+ kind: 'err',
34
+ message: `\`${field}\`: a segment value is 1–${MAX_VALUE_LENGTH} characters (key "${key}")`,
35
+ };
36
+ }
37
+ }
38
+ return { kind: 'ok', segments };
39
+ }
40
+
41
+ /**
42
+ * A run's or a pin's segment path from a JSON body: an ordered list of
43
+ * `{key, value}`, coarse to fine (e.g. `company` then `role`).
44
+ */
45
+ export function parseSegmentsBody(raw: unknown, field = 'segments'): Parsed {
46
+ if (raw === undefined) return { kind: 'ok' };
47
+ if (!Array.isArray(raw)) {
48
+ return {
49
+ kind: 'err',
50
+ message: `\`${field}\` must be an array of {key, value}, coarse to fine`,
51
+ };
52
+ }
53
+ const segments: ScopeSegment[] = [];
54
+ for (const item of raw) {
55
+ const { key, value } = (item ?? {}) as { key?: unknown; value?: unknown };
56
+ if (typeof key !== 'string' || typeof value !== 'string') {
57
+ return { kind: 'err', message: `\`${field}\`: each segment is {key: string, value: string}` };
58
+ }
59
+ segments.push({ key, value });
60
+ }
61
+ return checked(segments, field);
62
+ }
63
+
64
+ /** The same from a query: repeated `segment=key:value`, in order. */
65
+ export function parseSegmentsQuery(values: readonly string[]): Parsed {
66
+ const segments: ScopeSegment[] = [];
67
+ for (const raw of values) {
68
+ const colon = raw.indexOf(':');
69
+ if (colon <= 0) {
70
+ return { kind: 'err', message: `\`segment\` is key:value; got "${raw}"` };
71
+ }
72
+ segments.push({ key: raw.slice(0, colon), value: raw.slice(colon + 1) });
73
+ }
74
+ return checked(segments, 'segment');
75
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  TeamBinding,
11
11
  TeamMembership,
12
12
  TeamMembershipBinding,
13
+ TeamMembershipUpdateRoleOutcome,
13
14
  TeamPatch,
14
15
  TeamRole,
15
16
  TeamSpec,
@@ -19,7 +20,11 @@ import type { Cursor, OrgId, TeamId, TenantId, UserId } from '@kindgi/types';
19
20
  import { statusFor, toWireError } from '../errors.js';
20
21
  import type { Authorizer } from '../middleware/authorize.js';
21
22
  import type { AppEnv } from '../types.js';
22
- import { orgNotFoundError, slugConflictError } from './hierarchy-errors.js';
23
+ import {
24
+ membershipNotKeptInStepError,
25
+ orgNotFoundError,
26
+ slugConflictError,
27
+ } from './hierarchy-errors.js';
23
28
  import { clampLimit } from './pagination.js';
24
29
 
25
30
  /**
@@ -57,6 +62,34 @@ export function teamsRouter(
57
62
  ): Hono<AppEnv> {
58
63
  const r = new Hono<AppEnv>();
59
64
 
65
+ /**
66
+ * Change a member's role. With an authorizer, the row and its FGA tuple
67
+ * change together through the tenant-hierarchy binding, or the change
68
+ * is refused; without one, the membership binding alone.
69
+ */
70
+ async function updateMemberRole(
71
+ tenantId: TenantId,
72
+ teamId: TeamId,
73
+ userId: UserId,
74
+ role: TeamRole,
75
+ ): Promise<
76
+ | { readonly kind: 'done'; readonly outcome: TeamMembershipUpdateRoleOutcome }
77
+ | { readonly kind: 'refused'; readonly error: ReturnType<typeof membershipNotKeptInStepError> }
78
+ > {
79
+ if (authorizer === undefined) {
80
+ return {
81
+ kind: 'done',
82
+ outcome: await membershipBinding.updateRole(tenantId, teamId, userId, role),
83
+ };
84
+ }
85
+ if (tenantHierarchy.updateTeamMemberRole === undefined) {
86
+ return { kind: 'refused', error: membershipNotKeptInStepError('updateTeamMemberRole') };
87
+ }
88
+ const res = await tenantHierarchy.updateTeamMemberRole({ tenantId, teamId, userId, role });
89
+ if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
90
+ return { kind: 'done', outcome: res.value };
91
+ }
92
+
60
93
  // ---------- Authorization middleware ----------
61
94
  // Full PEP surface:
62
95
  // POST / → admin on tenant
@@ -363,7 +396,19 @@ export function teamsRouter(
363
396
  const tenantId = c.get('tenantId') as TenantId;
364
397
  const teamId = c.req.param('teamId') as TeamId;
365
398
  const userId = c.req.param('userId') as UserId;
366
- await membershipBinding.remove(tenantId, teamId, userId);
399
+ // With an authorizer, the row and its FGA tuple go together, or not
400
+ // at all: removing the row alone would leave the permission in place.
401
+ if (authorizer !== undefined) {
402
+ if (tenantHierarchy.removeTeamMember === undefined) {
403
+ const refusal = membershipNotKeptInStepError('removeTeamMember');
404
+ c.status(statusFor(refusal.code) as never);
405
+ return c.json(toWireError(refusal, c.get('requestId')));
406
+ }
407
+ const res = await tenantHierarchy.removeTeamMember({ tenantId, teamId, userId });
408
+ if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
409
+ } else {
410
+ await membershipBinding.remove(tenantId, teamId, userId);
411
+ }
367
412
  c.status(204);
368
413
  return c.body(null);
369
414
  });
@@ -403,7 +448,12 @@ export function teamsRouter(
403
448
  ),
404
449
  );
405
450
  }
406
- const outcome = await membershipBinding.updateRole(tenantId, teamId, userId, b.role);
451
+ const updated = await updateMemberRole(tenantId, teamId, userId, b.role);
452
+ if (updated.kind === 'refused') {
453
+ c.status(statusFor(updated.error.code) as never);
454
+ return c.json(toWireError(updated.error, requestId));
455
+ }
456
+ const { outcome } = updated;
407
457
  if (outcome.kind === 'team-membership-not-found') {
408
458
  c.status(statusFor('team-membership-not-found') as never);
409
459
  return c.json(
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { MiddlewareHandler } from 'hono';
5
+
6
+ import { statusFor, toWireError } from '../errors.js';
7
+ import type { AppEnv } from '../types.js';
8
+
9
+ /** A UUID, the shape of a run's and a conversation's id. */
10
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ /**
13
+ * A path parameter that must be a UUID: one that isn't is a 400 naming
14
+ * it, before it reaches a query (where Postgres's uuid cast would fail it
15
+ * as a 500). `what` names the id, e.g. `a run id`.
16
+ */
17
+ export function refuseMalformedUuidParam(param: string, what: string): MiddlewareHandler<AppEnv> {
18
+ return async (c, next) => {
19
+ if (UUID_RE.test(c.req.param(param) ?? '')) return next();
20
+ c.status(statusFor('bad-input') as never);
21
+ return c.json(
22
+ toWireError(
23
+ { code: 'bad-input', message: `\`${param}\` must be ${what} (a UUID)` },
24
+ c.get('requestId'),
25
+ ),
26
+ );
27
+ };
28
+ }