@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.
- package/dist/agent-binding.d.ts +12 -2
- package/dist/agent-binding.d.ts.map +1 -1
- package/dist/agent-pins.d.ts.map +1 -1
- package/dist/agent-pins.js +4 -1
- package/dist/agent-pins.js.map +1 -1
- package/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +23 -4
- package/dist/app.js.map +1 -1
- package/dist/deploy-versions.d.ts +8 -5
- package/dist/deploy-versions.d.ts.map +1 -1
- package/dist/deploy-versions.js +3 -9
- package/dist/deploy-versions.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/eval-case-binding.d.ts +10 -0
- package/dist/eval-case-binding.d.ts.map +1 -1
- package/dist/eval-run-binding.d.ts +14 -3
- package/dist/eval-run-binding.d.ts.map +1 -1
- package/dist/eval-run-binding.js.map +1 -1
- package/dist/flow-pins.d.ts +20 -7
- package/dist/flow-pins.d.ts.map +1 -1
- package/dist/flow-pins.js +36 -11
- package/dist/flow-pins.js.map +1 -1
- package/dist/gate-policy-binding.d.ts +164 -0
- package/dist/gate-policy-binding.d.ts.map +1 -0
- package/dist/gate-policy-binding.js +12 -0
- package/dist/gate-policy-binding.js.map +1 -0
- package/dist/gate.d.ts +56 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +359 -0
- package/dist/gate.js.map +1 -0
- package/dist/handler-binding.d.ts +14 -2
- package/dist/handler-binding.d.ts.map +1 -1
- package/dist/hitl-binding.d.ts +5 -0
- package/dist/hitl-binding.d.ts.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/judged-dispatcher.d.ts +16 -1
- package/dist/judged-dispatcher.d.ts.map +1 -1
- package/dist/judged-dispatcher.js +45 -6
- package/dist/judged-dispatcher.js.map +1 -1
- package/dist/judgment-binding.d.ts +38 -0
- package/dist/judgment-binding.d.ts.map +1 -1
- package/dist/judgment-binding.js +19 -0
- package/dist/judgment-binding.js.map +1 -1
- package/dist/live-version-binding.d.ts +206 -0
- package/dist/live-version-binding.d.ts.map +1 -0
- package/dist/live-version-binding.js +4 -0
- package/dist/live-version-binding.js.map +1 -0
- package/dist/middleware/authorize.d.ts.map +1 -1
- package/dist/middleware/authorize.js +13 -2
- package/dist/middleware/authorize.js.map +1 -1
- package/dist/middleware/project-ref.d.ts +25 -0
- package/dist/middleware/project-ref.d.ts.map +1 -0
- package/dist/middleware/project-ref.js +72 -0
- package/dist/middleware/project-ref.js.map +1 -0
- package/dist/openapi/generate.d.ts.map +1 -1
- package/dist/openapi/generate.js +4 -0
- package/dist/openapi/generate.js.map +1 -1
- package/dist/openapi/operations.d.ts +6 -0
- package/dist/openapi/operations.d.ts.map +1 -1
- package/dist/openapi/operations.js +512 -22
- package/dist/openapi/operations.js.map +1 -1
- package/dist/openapi/schemas.d.ts +37 -0
- package/dist/openapi/schemas.d.ts.map +1 -1
- package/dist/openapi/schemas.js +709 -5
- package/dist/openapi/schemas.js.map +1 -1
- package/dist/publish-refused.d.ts +18 -0
- package/dist/publish-refused.d.ts.map +1 -0
- package/dist/publish-refused.js +22 -0
- package/dist/publish-refused.js.map +1 -0
- package/dist/retention-binding.d.ts +29 -0
- package/dist/retention-binding.d.ts.map +1 -1
- package/dist/routes/agent-releases.d.ts +40 -0
- package/dist/routes/agent-releases.d.ts.map +1 -0
- package/dist/routes/agent-releases.js +462 -0
- package/dist/routes/agent-releases.js.map +1 -0
- package/dist/routes/agents.d.ts +7 -1
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +31 -3
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/approvals.d.ts.map +1 -1
- package/dist/routes/approvals.js +40 -9
- package/dist/routes/approvals.js.map +1 -1
- package/dist/routes/audit.d.ts.map +1 -1
- package/dist/routes/audit.js +7 -0
- package/dist/routes/audit.js.map +1 -1
- package/dist/routes/auth.js +1 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/conversations.d.ts +0 -7
- package/dist/routes/conversations.d.ts.map +1 -1
- package/dist/routes/conversations.js +19 -3
- package/dist/routes/conversations.js.map +1 -1
- package/dist/routes/deployments.d.ts +6 -0
- package/dist/routes/deployments.d.ts.map +1 -1
- package/dist/routes/deployments.js +33 -5
- package/dist/routes/deployments.js.map +1 -1
- package/dist/routes/env.d.ts.map +1 -1
- package/dist/routes/env.js +1 -0
- package/dist/routes/env.js.map +1 -1
- package/dist/routes/eval-comparison.d.ts +2 -1
- package/dist/routes/eval-comparison.d.ts.map +1 -1
- package/dist/routes/eval-comparison.js +22 -9
- package/dist/routes/eval-comparison.js.map +1 -1
- package/dist/routes/flows.d.ts +14 -7
- package/dist/routes/flows.d.ts.map +1 -1
- package/dist/routes/flows.js +10 -8
- package/dist/routes/flows.js.map +1 -1
- package/dist/routes/gate-policies.d.ts +19 -0
- package/dist/routes/gate-policies.d.ts.map +1 -0
- package/dist/routes/gate-policies.js +191 -0
- package/dist/routes/gate-policies.js.map +1 -0
- package/dist/routes/gate-policy-spec.d.ts +17 -0
- package/dist/routes/gate-policy-spec.d.ts.map +1 -0
- package/dist/routes/gate-policy-spec.js +192 -0
- package/dist/routes/gate-policy-spec.js.map +1 -0
- package/dist/routes/gate-policy-wire.d.ts +3 -0
- package/dist/routes/gate-policy-wire.d.ts.map +1 -0
- package/dist/routes/gate-policy-wire.js +18 -0
- package/dist/routes/gate-policy-wire.js.map +1 -0
- package/dist/routes/hierarchy-errors.d.ts +11 -0
- package/dist/routes/hierarchy-errors.d.ts.map +1 -1
- package/dist/routes/hierarchy-errors.js +13 -0
- package/dist/routes/hierarchy-errors.js.map +1 -1
- package/dist/routes/judged-suites.js +7 -0
- package/dist/routes/judged-suites.js.map +1 -1
- package/dist/routes/judgments.d.ts +4 -1
- package/dist/routes/judgments.d.ts.map +1 -1
- package/dist/routes/judgments.js +92 -19
- package/dist/routes/judgments.js.map +1 -1
- package/dist/routes/live-scope-wire.d.ts +32 -0
- package/dist/routes/live-scope-wire.d.ts.map +1 -0
- package/dist/routes/live-scope-wire.js +71 -0
- package/dist/routes/live-scope-wire.js.map +1 -0
- package/dist/routes/policies.d.ts +6 -3
- package/dist/routes/policies.d.ts.map +1 -1
- package/dist/routes/policies.js +55 -3
- package/dist/routes/policies.js.map +1 -1
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +47 -3
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/retention.d.ts.map +1 -1
- package/dist/routes/retention.js +18 -2
- package/dist/routes/retention.js.map +1 -1
- package/dist/routes/reviewers.d.ts +8 -1
- package/dist/routes/reviewers.d.ts.map +1 -1
- package/dist/routes/reviewers.js +20 -3
- package/dist/routes/reviewers.js.map +1 -1
- package/dist/routes/runs.d.ts.map +1 -1
- package/dist/routes/runs.js +22 -13
- package/dist/routes/runs.js.map +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +2 -0
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/segments.d.ts +17 -0
- package/dist/routes/segments.d.ts.map +1 -0
- package/dist/routes/segments.js +68 -0
- package/dist/routes/segments.js.map +1 -0
- package/dist/routes/teams.d.ts.map +1 -1
- package/dist/routes/teams.js +42 -3
- package/dist/routes/teams.js.map +1 -1
- package/dist/routes/uuid-param.d.ts +11 -0
- package/dist/routes/uuid-param.d.ts.map +1 -0
- package/dist/routes/uuid-param.js +19 -0
- package/dist/routes/uuid-param.js.map +1 -0
- package/openapi.json +7008 -3624
- package/package.json +21 -21
- package/src/agent-binding.ts +12 -2
- package/src/agent-pins.ts +3 -1
- package/src/app.ts +37 -3
- package/src/deploy-versions.ts +11 -14
- package/src/errors.ts +25 -0
- package/src/eval-case-binding.ts +7 -0
- package/src/eval-run-binding.ts +24 -3
- package/src/flow-pins.ts +49 -9
- package/src/gate-policy-binding.ts +171 -0
- package/src/gate.ts +467 -0
- package/src/handler-binding.ts +14 -2
- package/src/hitl-binding.ts +5 -0
- package/src/index.ts +46 -1
- package/src/judged-dispatcher.ts +74 -6
- package/src/judgment-binding.ts +62 -0
- package/src/live-version-binding.ts +237 -0
- package/src/middleware/authorize.ts +18 -2
- package/src/middleware/project-ref.ts +88 -0
- package/src/openapi/generate.ts +5 -0
- package/src/openapi/operations.ts +619 -22
- package/src/openapi/schemas.ts +788 -5
- package/src/publish-refused.ts +31 -0
- package/src/retention-binding.ts +30 -0
- package/src/routes/agent-releases.ts +581 -0
- package/src/routes/agents.ts +43 -2
- package/src/routes/approvals.ts +58 -8
- package/src/routes/audit.ts +10 -0
- package/src/routes/auth.ts +1 -1
- package/src/routes/conversations.ts +31 -3
- package/src/routes/deployments.ts +50 -5
- package/src/routes/env.ts +1 -0
- package/src/routes/eval-comparison.ts +24 -10
- package/src/routes/flows.ts +27 -11
- package/src/routes/gate-policies.ts +226 -0
- package/src/routes/gate-policy-spec.ts +229 -0
- package/src/routes/gate-policy-wire.ts +20 -0
- package/src/routes/hierarchy-errors.ts +14 -0
- package/src/routes/judged-suites.ts +5 -0
- package/src/routes/judgments.ts +107 -23
- package/src/routes/live-scope-wire.ts +90 -0
- package/src/routes/policies.ts +61 -3
- package/src/routes/projects.ts +54 -2
- package/src/routes/retention.ts +21 -2
- package/src/routes/reviewers.ts +23 -4
- package/src/routes/runs.ts +33 -20
- package/src/routes/secrets.ts +2 -0
- package/src/routes/segments.ts +75 -0
- package/src/routes/teams.ts +53 -3
- package/src/routes/uuid-param.ts +28 -0
package/src/routes/judgments.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { type Context, Hono } from 'hono';
|
|
5
5
|
|
|
6
6
|
import type { ConversationBinding } from '@kindgi/agents';
|
|
7
|
-
import { ref } from '@kindgi/authz';
|
|
7
|
+
import { REVIEWER_ROLE_RANK, type ReviewerRole, ref } from '@kindgi/authz';
|
|
8
8
|
import type { RunBinding } from '@kindgi/runtime';
|
|
9
9
|
import type { Cursor, ProjectId, RunId, TenantId } from '@kindgi/types';
|
|
10
10
|
|
|
@@ -12,6 +12,7 @@ import { statusFor, toWireError } from '../errors.js';
|
|
|
12
12
|
import type { FlowRegistryBinding } from '../flow-binding.js';
|
|
13
13
|
import {
|
|
14
14
|
type JudgeClass,
|
|
15
|
+
type JudgeClassAssertableBy,
|
|
15
16
|
type JudgeClassScope,
|
|
16
17
|
type JudgedItem,
|
|
17
18
|
type JudgedRunContext,
|
|
@@ -23,8 +24,11 @@ import {
|
|
|
23
24
|
VERDICTS,
|
|
24
25
|
type Verdict,
|
|
25
26
|
judgeClassApplies,
|
|
27
|
+
whyNotAssertable,
|
|
26
28
|
} from '../judgment-binding.js';
|
|
27
29
|
import type { Authorizer } from '../middleware/authorize.js';
|
|
30
|
+
import type { ReviewerBinding } from '../reviewer-binding.js';
|
|
31
|
+
import { callerReviewerRole } from '../reviewer-role.js';
|
|
28
32
|
import type { AppEnv } from '../types.js';
|
|
29
33
|
import { captureTurnContext } from './judgment-context.js';
|
|
30
34
|
import { captureFlowContext } from './judgment-flow-context.js';
|
|
@@ -52,6 +56,8 @@ export function judgmentsRouter(
|
|
|
52
56
|
authorizer?: Authorizer,
|
|
53
57
|
conversations?: ConversationBinding,
|
|
54
58
|
flows?: FlowRegistryBinding,
|
|
59
|
+
/** Whose reviewer role a restricted judge class checks (T200). */
|
|
60
|
+
reviewers?: ReviewerBinding,
|
|
55
61
|
): Hono<AppEnv> {
|
|
56
62
|
const r = new Hono<AppEnv>();
|
|
57
63
|
|
|
@@ -72,9 +78,12 @@ export function judgmentsRouter(
|
|
|
72
78
|
return fail('permission-denied', 'Judging needs a user or a service token.');
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
const prepared = await prepareJudgment(c, body, runBinding, binding, authorizer
|
|
81
|
+
const prepared = await prepareJudgment(c, body, runBinding, binding, authorizer, {
|
|
82
|
+
asserted,
|
|
83
|
+
reviewers,
|
|
84
|
+
});
|
|
76
85
|
if (prepared.kind === 'err') return fail(prepared.code, prepared.message);
|
|
77
|
-
const { run, subject, projectId, itemValue, conversationId } = prepared;
|
|
86
|
+
const { run, subject, projectId, itemValue, conversationId, restricted } = prepared;
|
|
78
87
|
const context = (await isFirstJudgment(binding, tenantId, body.runId))
|
|
79
88
|
? await captureContext({
|
|
80
89
|
tenantId,
|
|
@@ -103,6 +112,7 @@ export function judgmentsRouter(
|
|
|
103
112
|
verdict: body.verdict,
|
|
104
113
|
...(body.reason !== undefined && { reason: body.reason }),
|
|
105
114
|
...(body.judgeClassId !== undefined && { judgeClassId: body.judgeClassId }),
|
|
115
|
+
...(restricted === true && { restricted }),
|
|
106
116
|
assertedBy: asserted,
|
|
107
117
|
...(body.participantId !== undefined && { participantId: body.participantId }),
|
|
108
118
|
});
|
|
@@ -239,7 +249,7 @@ export function judgeClassesRouter(
|
|
|
239
249
|
};
|
|
240
250
|
const parsed = parseClassBody(await c.req.json().catch(() => null));
|
|
241
251
|
if (parsed.kind === 'err') return fail('bad-input', parsed.message);
|
|
242
|
-
const { scope, name, weight, description } = parsed.body;
|
|
252
|
+
const { scope, name, weight, description, assertableBy } = parsed.body;
|
|
243
253
|
if (
|
|
244
254
|
authorizer !== undefined &&
|
|
245
255
|
!(await authorizer.can(c, 'admin', scopeRef(tenantId, scope)))
|
|
@@ -252,6 +262,7 @@ export function judgeClassesRouter(
|
|
|
252
262
|
name,
|
|
253
263
|
weight,
|
|
254
264
|
...(description !== undefined && { description }),
|
|
265
|
+
...(assertableBy !== undefined && { assertableBy }),
|
|
255
266
|
});
|
|
256
267
|
if (outcome.kind === 'name-taken') {
|
|
257
268
|
return fail('judge-class-name-taken', `A judge class named "${name}" already exists here.`);
|
|
@@ -332,19 +343,25 @@ export function judgeClassesRouter(
|
|
|
332
343
|
const raw = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
333
344
|
const weight = raw?.weight;
|
|
334
345
|
const description = raw?.description;
|
|
346
|
+
const assertableBy =
|
|
347
|
+
raw?.assertableBy === undefined ? undefined : parseAssertableBy(raw.assertableBy);
|
|
335
348
|
if (
|
|
336
349
|
raw === null ||
|
|
337
350
|
typeof raw !== 'object' ||
|
|
338
|
-
(weight === undefined && description === undefined) ||
|
|
351
|
+
(weight === undefined && description === undefined && assertableBy === undefined) ||
|
|
339
352
|
(weight !== undefined && !isWeight(weight)) ||
|
|
340
|
-
(description !== undefined && typeof description !== 'string')
|
|
353
|
+
(description !== undefined && typeof description !== 'string') ||
|
|
354
|
+
typeof assertableBy === 'string'
|
|
341
355
|
) {
|
|
342
356
|
c.status(statusFor('bad-input') as never);
|
|
343
357
|
return c.json(
|
|
344
358
|
toWireError(
|
|
345
359
|
{
|
|
346
360
|
code: 'bad-input',
|
|
347
|
-
message:
|
|
361
|
+
message:
|
|
362
|
+
typeof assertableBy === 'string'
|
|
363
|
+
? assertableBy
|
|
364
|
+
: 'Send weight (a number ≥ 0), description (a string) and/or assertableBy (who may assert the class, or null for anyone).',
|
|
348
365
|
},
|
|
349
366
|
requestId,
|
|
350
367
|
),
|
|
@@ -358,6 +375,7 @@ export function judgeClassesRouter(
|
|
|
358
375
|
judgeClassId: found.id,
|
|
359
376
|
...(weight !== undefined && { weight: weight as number }),
|
|
360
377
|
...(description !== undefined && { description: description as string }),
|
|
378
|
+
...(assertableBy !== undefined && { assertableBy }),
|
|
361
379
|
});
|
|
362
380
|
return updated === null ? classNotFound(c, found.id) : c.json(serializeJudgeClass(updated));
|
|
363
381
|
});
|
|
@@ -382,6 +400,8 @@ type Prepared =
|
|
|
382
400
|
readonly projectId: ProjectId;
|
|
383
401
|
readonly itemValue?: unknown;
|
|
384
402
|
readonly conversationId?: string;
|
|
403
|
+
/** The class was restricted and the caller met it. */
|
|
404
|
+
readonly restricted?: true;
|
|
385
405
|
}
|
|
386
406
|
| { readonly kind: 'err'; readonly code: string; readonly message: string };
|
|
387
407
|
|
|
@@ -396,6 +416,7 @@ async function prepareJudgment(
|
|
|
396
416
|
runBinding: RunBinding,
|
|
397
417
|
binding: JudgmentRegistryBinding,
|
|
398
418
|
authorizer: Authorizer | undefined,
|
|
419
|
+
who: { readonly asserted: JudgmentAssertedBy; readonly reviewers: ReviewerBinding | undefined },
|
|
399
420
|
): Promise<Prepared> {
|
|
400
421
|
const err = (code: string, message: string): Prepared => ({ kind: 'err', code, message });
|
|
401
422
|
const tenantId = c.get('tenantId') as TenantId;
|
|
@@ -417,20 +438,37 @@ async function prepareJudgment(
|
|
|
417
438
|
}
|
|
418
439
|
const subject = subjectOf(run);
|
|
419
440
|
const projectId = run.projectId as unknown as ProjectId;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
441
|
+
let restricted = false;
|
|
442
|
+
if (body.judgeClassId !== undefined) {
|
|
443
|
+
const judgeClass = await applicableClass(binding, tenantId, body.judgeClassId, {
|
|
444
|
+
projectId,
|
|
445
|
+
subject,
|
|
446
|
+
});
|
|
447
|
+
if (judgeClass === null) {
|
|
448
|
+
return err(
|
|
449
|
+
'judge-class-not-applicable',
|
|
450
|
+
`Judge class "${body.judgeClassId}" doesn't exist or doesn't apply to this run's project or agent.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (judgeClass.assertableBy !== undefined) {
|
|
454
|
+
const reviewerRole = await callerReviewerRole(c, who.reviewers);
|
|
455
|
+
const why = whyNotAssertable(judgeClass.assertableBy, {
|
|
456
|
+
...who.asserted,
|
|
457
|
+
...(reviewerRole !== undefined && { reviewerRole }),
|
|
458
|
+
});
|
|
459
|
+
if (why !== undefined) {
|
|
460
|
+
return err('judge-class-not-allowed', `You can't judge as "${judgeClass.name}": ${why}.`);
|
|
461
|
+
}
|
|
462
|
+
restricted = true;
|
|
463
|
+
}
|
|
428
464
|
}
|
|
429
465
|
const copy = { input: run.input, output: run.output };
|
|
430
|
-
const
|
|
431
|
-
run.agent !== undefined
|
|
466
|
+
const extra = {
|
|
467
|
+
...(run.agent !== undefined && { conversationId: run.agent.conversationId as string }),
|
|
468
|
+
...(restricted && { restricted: true as const }),
|
|
469
|
+
};
|
|
432
470
|
if (body.item.pointer === undefined)
|
|
433
|
-
return { kind: 'ok', run: copy, subject, projectId, ...
|
|
471
|
+
return { kind: 'ok', run: copy, subject, projectId, ...extra };
|
|
434
472
|
const found = resolvePointer(run.output, body.item.pointer);
|
|
435
473
|
if (!found.found) {
|
|
436
474
|
return err(
|
|
@@ -438,7 +476,7 @@ async function prepareJudgment(
|
|
|
438
476
|
`Nothing at "${body.item.pointer}" in run "${body.runId}"'s output.`,
|
|
439
477
|
);
|
|
440
478
|
}
|
|
441
|
-
return { kind: 'ok', run: copy, subject, projectId, itemValue: found.value, ...
|
|
479
|
+
return { kind: 'ok', run: copy, subject, projectId, itemValue: found.value, ...extra };
|
|
442
480
|
}
|
|
443
481
|
|
|
444
482
|
/** Whether the run has no live judgment yet (its copy is stored with the first). */
|
|
@@ -484,15 +522,15 @@ async function isFirstJudgment(
|
|
|
484
522
|
return page.data.length === 0;
|
|
485
523
|
}
|
|
486
524
|
|
|
487
|
-
/**
|
|
488
|
-
async function
|
|
525
|
+
/** The live class, when it exists and its scope covers the run; else `null`. */
|
|
526
|
+
async function applicableClass(
|
|
489
527
|
binding: JudgmentRegistryBinding,
|
|
490
528
|
tenantId: TenantId,
|
|
491
529
|
judgeClassId: string,
|
|
492
530
|
run: { readonly projectId: ProjectId; readonly subject: JudgedSubject },
|
|
493
|
-
): Promise<
|
|
531
|
+
): Promise<JudgeClass | null> {
|
|
494
532
|
const judgeClass = await binding.getClass({ tenantId, judgeClassId });
|
|
495
|
-
return judgeClass !== null && judgeClassApplies(judgeClass.scope, run);
|
|
533
|
+
return judgeClass !== null && judgeClassApplies(judgeClass.scope, run) ? judgeClass : null;
|
|
496
534
|
}
|
|
497
535
|
|
|
498
536
|
// ---------- request parsing ----------
|
|
@@ -573,6 +611,47 @@ interface ClassBody {
|
|
|
573
611
|
readonly name: string;
|
|
574
612
|
readonly weight: number;
|
|
575
613
|
readonly description?: string;
|
|
614
|
+
readonly assertableBy?: JudgeClassAssertableBy;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const ASSERTABLE_BY_USAGE =
|
|
618
|
+
'assertableBy says who may assert the class: { minReviewerRole?: "standard" | "senior" | "admin", principalKinds?: ["user" | "service", …], principalIds?: [id, …] }, with at least one of them.';
|
|
619
|
+
|
|
620
|
+
/** `assertableBy`: a restriction, `null` (on an update: lift it), or what's wrong. */
|
|
621
|
+
function parseAssertableBy(raw: unknown): JudgeClassAssertableBy | null | string {
|
|
622
|
+
if (raw === null) return null;
|
|
623
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return ASSERTABLE_BY_USAGE;
|
|
624
|
+
const b = raw as Record<string, unknown>;
|
|
625
|
+
const keys = Object.keys(b);
|
|
626
|
+
const known = ['minReviewerRole', 'principalKinds', 'principalIds'];
|
|
627
|
+
if (keys.length === 0 || keys.some((k) => !known.includes(k))) return ASSERTABLE_BY_USAGE;
|
|
628
|
+
const role = b.minReviewerRole;
|
|
629
|
+
if (role !== undefined && !(typeof role === 'string' && role in REVIEWER_ROLE_RANK)) {
|
|
630
|
+
return ASSERTABLE_BY_USAGE;
|
|
631
|
+
}
|
|
632
|
+
const kinds = b.principalKinds;
|
|
633
|
+
if (
|
|
634
|
+
kinds !== undefined &&
|
|
635
|
+
!(
|
|
636
|
+
Array.isArray(kinds) &&
|
|
637
|
+
kinds.length > 0 &&
|
|
638
|
+
kinds.every((k) => k === 'user' || k === 'service')
|
|
639
|
+
)
|
|
640
|
+
) {
|
|
641
|
+
return ASSERTABLE_BY_USAGE;
|
|
642
|
+
}
|
|
643
|
+
const ids = b.principalIds;
|
|
644
|
+
if (
|
|
645
|
+
ids !== undefined &&
|
|
646
|
+
!(Array.isArray(ids) && ids.length > 0 && ids.length <= 100 && ids.every(nonEmpty))
|
|
647
|
+
) {
|
|
648
|
+
return ASSERTABLE_BY_USAGE;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
...(role !== undefined && { minReviewerRole: role as ReviewerRole }),
|
|
652
|
+
...(kinds !== undefined && { principalKinds: kinds as ('user' | 'service')[] }),
|
|
653
|
+
...(ids !== undefined && { principalIds: ids as string[] }),
|
|
654
|
+
};
|
|
576
655
|
}
|
|
577
656
|
|
|
578
657
|
function parseClassBody(raw: unknown): Parsed<ClassBody> {
|
|
@@ -587,6 +666,8 @@ function parseClassBody(raw: unknown): Parsed<ClassBody> {
|
|
|
587
666
|
}
|
|
588
667
|
const scope = parseClassScope(b.scope);
|
|
589
668
|
if (typeof scope === 'string') return err(scope);
|
|
669
|
+
const assertableBy = b.assertableBy === undefined ? undefined : parseAssertableBy(b.assertableBy);
|
|
670
|
+
if (typeof assertableBy === 'string') return err(assertableBy);
|
|
590
671
|
return {
|
|
591
672
|
kind: 'ok',
|
|
592
673
|
body: {
|
|
@@ -594,6 +675,7 @@ function parseClassBody(raw: unknown): Parsed<ClassBody> {
|
|
|
594
675
|
name: b.name,
|
|
595
676
|
weight: b.weight,
|
|
596
677
|
...(typeof b.description === 'string' && { description: b.description }),
|
|
678
|
+
...(assertableBy !== undefined && assertableBy !== null && { assertableBy }),
|
|
597
679
|
},
|
|
598
680
|
};
|
|
599
681
|
}
|
|
@@ -712,6 +794,7 @@ function serializeJudgment(j: Judgment): Record<string, unknown> {
|
|
|
712
794
|
verdict: j.verdict,
|
|
713
795
|
...(j.reason !== undefined && { reason: j.reason }),
|
|
714
796
|
...(j.judgeClassId !== undefined && { judgeClassId: j.judgeClassId }),
|
|
797
|
+
...(j.restricted === true && { restricted: true }),
|
|
715
798
|
assertedBy: j.assertedBy,
|
|
716
799
|
...(j.participantId !== undefined && { participantId: j.participantId }),
|
|
717
800
|
createdAt: j.createdAt,
|
|
@@ -736,6 +819,7 @@ function serializeJudgeClass(k: JudgeClass): Record<string, unknown> {
|
|
|
736
819
|
name: k.name,
|
|
737
820
|
weight: k.weight,
|
|
738
821
|
...(k.description !== undefined && { description: k.description }),
|
|
822
|
+
...(k.assertableBy !== undefined && { assertableBy: k.assertableBy }),
|
|
739
823
|
createdAt: k.createdAt,
|
|
740
824
|
updatedAt: k.updatedAt,
|
|
741
825
|
...(k.unregisteredAt !== undefined && { unregisteredAt: k.unregisteredAt }),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { LiveScope, OrgId, ProjectId } from '@kindgi/types';
|
|
5
|
+
|
|
6
|
+
import { parseSegmentsBody } from './segments.js';
|
|
7
|
+
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
|
|
10
|
+
/** A live scope on the wire: `{kind}` plus the id or path it needs. */
|
|
11
|
+
export type WireLiveScope =
|
|
12
|
+
| { readonly kind: 'tenant' }
|
|
13
|
+
| { readonly kind: 'org'; readonly orgId: string }
|
|
14
|
+
| { readonly kind: 'project'; readonly projectId: string }
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: 'segment';
|
|
17
|
+
readonly projectId: string;
|
|
18
|
+
readonly path: readonly { readonly key: string; readonly value: string }[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function liveScopeToWire(scope: LiveScope): WireLiveScope {
|
|
22
|
+
switch (scope.kind) {
|
|
23
|
+
case 'tenant':
|
|
24
|
+
return { kind: 'tenant' };
|
|
25
|
+
case 'org':
|
|
26
|
+
return { kind: 'org', orgId: scope.orgId as unknown as string };
|
|
27
|
+
case 'project':
|
|
28
|
+
return { kind: 'project', projectId: scope.projectId as unknown as string };
|
|
29
|
+
case 'segment':
|
|
30
|
+
return {
|
|
31
|
+
kind: 'segment',
|
|
32
|
+
projectId: scope.projectId as unknown as string,
|
|
33
|
+
path: scope.path.map(({ key, value }) => ({ key, value })),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A live scope from a request body (`scope`): `{kind: 'tenant'}`,
|
|
40
|
+
* `{kind: 'org', orgId}`, `{kind: 'project', projectId}` or
|
|
41
|
+
* `{kind: 'segment', projectId, path: [{key, value}, …]}`.
|
|
42
|
+
*/
|
|
43
|
+
export function parseLiveScopeBody(
|
|
44
|
+
raw: unknown,
|
|
45
|
+
):
|
|
46
|
+
| { readonly kind: 'ok'; readonly scope: LiveScope }
|
|
47
|
+
| { readonly kind: 'err'; readonly message: string } {
|
|
48
|
+
const b = (raw ?? {}) as Record<string, unknown>;
|
|
49
|
+
const id = (field: 'orgId' | 'projectId'): string | undefined => {
|
|
50
|
+
const v = b[field];
|
|
51
|
+
return typeof v === 'string' && UUID_RE.test(v) ? v : undefined;
|
|
52
|
+
};
|
|
53
|
+
switch (b.kind) {
|
|
54
|
+
case 'tenant':
|
|
55
|
+
return { kind: 'ok', scope: { kind: 'tenant' } };
|
|
56
|
+
case 'org': {
|
|
57
|
+
const orgId = id('orgId');
|
|
58
|
+
if (orgId === undefined)
|
|
59
|
+
return { kind: 'err', message: '`scope.orgId` must be an org id (a UUID)' };
|
|
60
|
+
return { kind: 'ok', scope: { kind: 'org', orgId: orgId as OrgId } };
|
|
61
|
+
}
|
|
62
|
+
case 'project': {
|
|
63
|
+
const projectId = id('projectId');
|
|
64
|
+
if (projectId === undefined) {
|
|
65
|
+
return { kind: 'err', message: '`scope.projectId` must be a project id (a UUID)' };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'ok', scope: { kind: 'project', projectId: projectId as ProjectId } };
|
|
68
|
+
}
|
|
69
|
+
case 'segment': {
|
|
70
|
+
const projectId = id('projectId');
|
|
71
|
+
if (projectId === undefined) {
|
|
72
|
+
return { kind: 'err', message: '`scope.projectId` must be a project id (a UUID)' };
|
|
73
|
+
}
|
|
74
|
+
const path = parseSegmentsBody(b.path, 'scope.path');
|
|
75
|
+
if (path.kind === 'err') return path;
|
|
76
|
+
if (path.segments === undefined) {
|
|
77
|
+
return { kind: 'err', message: '`scope.path` needs at least one segment' };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind: 'ok',
|
|
81
|
+
scope: { kind: 'segment', projectId: projectId as ProjectId, path: path.segments },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return {
|
|
86
|
+
kind: 'err',
|
|
87
|
+
message: '`scope.kind` must be one of tenant, org, project, segment',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/routes/policies.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
type Policy,
|
|
12
12
|
type PolicyKind,
|
|
13
13
|
type PolicyRegistryBinding,
|
|
14
|
+
type PolicyScopeChanged,
|
|
15
|
+
type PolicyScopeTaken,
|
|
14
16
|
isAppliedPolicyKind,
|
|
15
17
|
validatePolicySpec,
|
|
16
18
|
} from '@kindgi/policy-contract';
|
|
@@ -27,9 +29,12 @@ import { clampLimit } from './pagination.js';
|
|
|
27
29
|
* that kind. The route validates the top-level shape (id, tenantId,
|
|
28
30
|
* semver version, kind ∈ closed enum, spec is an object), and `spec`
|
|
29
31
|
* against its kind's contract where `@kindgi/policy-contract` has one
|
|
30
|
-
* (`tool-errors`, `hitl`) — a policy that couldn't be
|
|
31
|
-
* here, not found out on a turn
|
|
32
|
-
* consumer's to validate.
|
|
32
|
+
* (`tool-errors`, `hitl`, `retention`) — a policy that couldn't be
|
|
33
|
+
* applied is refused here, not found out on a turn or a sweep. Other
|
|
34
|
+
* kinds' specs are their runtime consumer's to validate. A kind that
|
|
35
|
+
* holds a scope (`policyScope`: a retention policy, its domain) gets one
|
|
36
|
+
* policy per scope; the binding enforces it (`409 policy-scope-taken`,
|
|
37
|
+
* `409 policy-scope-changed`).
|
|
33
38
|
*
|
|
34
39
|
* Enforcement is out of scope. This is the *registry* surface only.
|
|
35
40
|
* Runtime consumers (e.g. `@kindgi/capabilities` `route` for
|
|
@@ -255,6 +260,10 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
|
|
|
255
260
|
),
|
|
256
261
|
);
|
|
257
262
|
}
|
|
263
|
+
if (outcome.kind === 'scope-taken' || outcome.kind === 'scope-changed') {
|
|
264
|
+
c.status(statusFor(`policy-${outcome.kind}`) as never);
|
|
265
|
+
return c.json(toWireError(scopeError(outcome, 'publish'), requestId));
|
|
266
|
+
}
|
|
258
267
|
c.status(201);
|
|
259
268
|
return c.json({ policyId: outcome.policyId, version: outcome.version });
|
|
260
269
|
});
|
|
@@ -305,6 +314,10 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
|
|
|
305
314
|
),
|
|
306
315
|
);
|
|
307
316
|
}
|
|
317
|
+
if (outcome.kind === 'scope-taken' || outcome.kind === 'scope-changed') {
|
|
318
|
+
c.status(statusFor(`policy-${outcome.kind}`) as never);
|
|
319
|
+
return c.json(toWireError(scopeError(outcome, 'reinstate'), requestId));
|
|
320
|
+
}
|
|
308
321
|
return c.json({
|
|
309
322
|
policyId: outcome.policyId,
|
|
310
323
|
version: outcome.version,
|
|
@@ -315,6 +328,51 @@ export function policiesRouter(binding: PolicyRegistryBinding): Hono<AppEnv> {
|
|
|
315
328
|
return r;
|
|
316
329
|
}
|
|
317
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The 409 for a policy whose scope (`policyScope`) another policy holds,
|
|
333
|
+
* or that a version would move: for `retention`, a second policy for a
|
|
334
|
+
* domain, which a tenant can't have.
|
|
335
|
+
*/
|
|
336
|
+
function scopeError(
|
|
337
|
+
outcome: PolicyScopeTaken | PolicyScopeChanged,
|
|
338
|
+
action: 'publish' | 'reinstate',
|
|
339
|
+
): { code: string; message: string } & Record<string, unknown> {
|
|
340
|
+
const { policyId, version, policyKind, scope } = outcome;
|
|
341
|
+
const retention = policyKind === 'retention';
|
|
342
|
+
if (outcome.kind === 'scope-taken') {
|
|
343
|
+
const { heldBy } = outcome;
|
|
344
|
+
const message = !retention
|
|
345
|
+
? `Policy "${heldBy}" already holds "${scope}" for ${policyKind} policies.`
|
|
346
|
+
: action === 'publish'
|
|
347
|
+
? `Retention domain "${scope}" is already covered by policy "${heldBy}": a tenant has one retention policy per domain, and one for "*". Publish a new version of "${heldBy}" instead, or unregister it first.`
|
|
348
|
+
: `Retention domain "${scope}" is now covered by policy "${heldBy}", so reinstating "${policyId}" ${version} would make two: a tenant has one retention policy per domain, and one for "*". Unregister "${heldBy}" first.`;
|
|
349
|
+
return {
|
|
350
|
+
code: 'policy-scope-taken',
|
|
351
|
+
message,
|
|
352
|
+
policyId,
|
|
353
|
+
version,
|
|
354
|
+
policyKind,
|
|
355
|
+
scope,
|
|
356
|
+
heldBy,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const { previousScope } = outcome;
|
|
360
|
+
const message = !retention
|
|
361
|
+
? `Policy "${policyId}" holds "${previousScope}"; version ${version} can't move it to "${scope}".`
|
|
362
|
+
: action === 'publish'
|
|
363
|
+
? `Policy "${policyId}" covers retention domain "${previousScope}", and a new version can't move it to "${scope}". Publish a policy for "${scope}" under a new id.`
|
|
364
|
+
: `Version ${version} of policy "${policyId}" covers retention domain "${scope}", but its other versions cover "${previousScope}": a policy keeps one domain. Publish it under a new id instead.`;
|
|
365
|
+
return {
|
|
366
|
+
code: 'policy-scope-changed',
|
|
367
|
+
message,
|
|
368
|
+
policyId,
|
|
369
|
+
version,
|
|
370
|
+
policyKind,
|
|
371
|
+
scope,
|
|
372
|
+
previousScope,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
318
376
|
function serializePolicy(p: Policy): Record<string, unknown> {
|
|
319
377
|
return {
|
|
320
378
|
id: p.id,
|
package/src/routes/projects.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ProjectBinding,
|
|
11
11
|
ProjectMembership,
|
|
12
12
|
ProjectMembershipBinding,
|
|
13
|
+
ProjectMembershipUpdateRoleOutcome,
|
|
13
14
|
ProjectPatch,
|
|
14
15
|
ProjectRole,
|
|
15
16
|
ProjectSpec,
|
|
@@ -20,6 +21,7 @@ import { statusFor, toWireError } from '../errors.js';
|
|
|
20
21
|
import type { Authorizer } from '../middleware/authorize.js';
|
|
21
22
|
import type { AppEnv } from '../types.js';
|
|
22
23
|
import {
|
|
24
|
+
membershipNotKeptInStepError,
|
|
23
25
|
orgNotFoundError,
|
|
24
26
|
projectDefaultAlreadyExistsError,
|
|
25
27
|
slugConflictError,
|
|
@@ -78,6 +80,39 @@ export function projectsRouter(
|
|
|
78
80
|
): Hono<AppEnv> {
|
|
79
81
|
const r = new Hono<AppEnv>();
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Change a member's role. With an authorizer, the row and its FGA tuple
|
|
85
|
+
* change together through the tenant-hierarchy binding, or the change
|
|
86
|
+
* is refused; without one, the membership binding alone.
|
|
87
|
+
*/
|
|
88
|
+
async function updateMemberRole(
|
|
89
|
+
tenantId: TenantId,
|
|
90
|
+
projectId: ProjectId,
|
|
91
|
+
userId: UserId,
|
|
92
|
+
role: ProjectRole,
|
|
93
|
+
): Promise<
|
|
94
|
+
| { readonly kind: 'done'; readonly outcome: ProjectMembershipUpdateRoleOutcome }
|
|
95
|
+
| { readonly kind: 'refused'; readonly error: ReturnType<typeof membershipNotKeptInStepError> }
|
|
96
|
+
> {
|
|
97
|
+
if (authorizer === undefined) {
|
|
98
|
+
return {
|
|
99
|
+
kind: 'done',
|
|
100
|
+
outcome: await membershipBinding.updateRole(tenantId, projectId, userId, role),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (tenantHierarchy.updateProjectMemberRole === undefined) {
|
|
104
|
+
return { kind: 'refused', error: membershipNotKeptInStepError('updateProjectMemberRole') };
|
|
105
|
+
}
|
|
106
|
+
const res = await tenantHierarchy.updateProjectMemberRole({
|
|
107
|
+
tenantId,
|
|
108
|
+
projectId,
|
|
109
|
+
userId,
|
|
110
|
+
role,
|
|
111
|
+
});
|
|
112
|
+
if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
|
|
113
|
+
return { kind: 'done', outcome: res.value };
|
|
114
|
+
}
|
|
115
|
+
|
|
81
116
|
// ---------- GET /default (literal segment; must precede /:projectId) ----------
|
|
82
117
|
r.get('/default', async (c) => {
|
|
83
118
|
const requestId = c.get('requestId');
|
|
@@ -420,7 +455,19 @@ export function projectsRouter(
|
|
|
420
455
|
const tenantId = c.get('tenantId') as TenantId;
|
|
421
456
|
const projectId = c.req.param('projectId') as ProjectId;
|
|
422
457
|
const userId = c.req.param('userId') as UserId;
|
|
423
|
-
|
|
458
|
+
// With an authorizer, the row and its FGA tuple go together, or not
|
|
459
|
+
// at all: removing the row alone would leave the permission in place.
|
|
460
|
+
if (authorizer !== undefined) {
|
|
461
|
+
if (tenantHierarchy.removeProjectMember === undefined) {
|
|
462
|
+
const refusal = membershipNotKeptInStepError('removeProjectMember');
|
|
463
|
+
c.status(statusFor(refusal.code) as never);
|
|
464
|
+
return c.json(toWireError(refusal, c.get('requestId')));
|
|
465
|
+
}
|
|
466
|
+
const res = await tenantHierarchy.removeProjectMember({ tenantId, projectId, userId });
|
|
467
|
+
if (res.kind === 'err') throw new Error(res.error.message, { cause: res.error });
|
|
468
|
+
} else {
|
|
469
|
+
await membershipBinding.remove(tenantId, projectId, userId);
|
|
470
|
+
}
|
|
424
471
|
c.status(204);
|
|
425
472
|
return c.body(null);
|
|
426
473
|
});
|
|
@@ -460,7 +507,12 @@ export function projectsRouter(
|
|
|
460
507
|
),
|
|
461
508
|
);
|
|
462
509
|
}
|
|
463
|
-
const
|
|
510
|
+
const updated = await updateMemberRole(tenantId, projectId, userId, b.role);
|
|
511
|
+
if (updated.kind === 'refused') {
|
|
512
|
+
c.status(statusFor(updated.error.code) as never);
|
|
513
|
+
return c.json(toWireError(updated.error, requestId));
|
|
514
|
+
}
|
|
515
|
+
const { outcome } = updated;
|
|
464
516
|
if (outcome.kind === 'project-membership-not-found') {
|
|
465
517
|
c.status(statusFor('project-membership-not-found') as never);
|
|
466
518
|
return c.json(
|
package/src/routes/retention.ts
CHANGED
|
@@ -62,15 +62,21 @@ export function retentionRouter(binding: RetentionBinding, authorizer?: Authoriz
|
|
|
62
62
|
const limit = clampLimit(url.searchParams.get('limit') ?? undefined);
|
|
63
63
|
const pastGraceRaw = url.searchParams.get('pastGraceOnly');
|
|
64
64
|
const pastGraceOnly = pastGraceRaw === 'true';
|
|
65
|
+
const cursor = url.searchParams.get('cursor') ?? '';
|
|
65
66
|
|
|
66
|
-
const page = await binding.scheduled({
|
|
67
|
+
const { hasMore, nextCursor, ...page } = await binding.scheduled({
|
|
67
68
|
tenantId,
|
|
68
69
|
limit,
|
|
69
70
|
pastGraceOnly,
|
|
70
71
|
...(domain !== undefined && { domain }),
|
|
72
|
+
...(cursor !== '' && { cursor }),
|
|
71
73
|
});
|
|
72
74
|
|
|
73
|
-
return c.json(
|
|
75
|
+
return c.json({
|
|
76
|
+
...page,
|
|
77
|
+
hasMore: hasMore ?? someDomainFilled(page.data, limit),
|
|
78
|
+
...(nextCursor !== undefined && { nextCursor }),
|
|
79
|
+
});
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
r.post('/sweep', async (c) => {
|
|
@@ -153,6 +159,19 @@ export function retentionRouter(binding: RetentionBinding, authorizer?: Authoriz
|
|
|
153
159
|
return r;
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* A runtime that doesn't report `hasMore`: there may be more when some
|
|
164
|
+
* domain's rows fill `limit` (each domain is read up to `limit`).
|
|
165
|
+
*/
|
|
166
|
+
function someDomainFilled(
|
|
167
|
+
data: readonly { readonly domain: RetentionDomain }[],
|
|
168
|
+
limit: number,
|
|
169
|
+
): boolean {
|
|
170
|
+
const perDomain = new Map<RetentionDomain, number>();
|
|
171
|
+
for (const row of data) perDomain.set(row.domain, (perDomain.get(row.domain) ?? 0) + 1);
|
|
172
|
+
return [...perDomain.values()].some((n) => n >= limit);
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
function parseDomain(raw: string | null | undefined): RetentionDomain | 'invalid' | undefined {
|
|
157
176
|
if (raw === null || raw === undefined || raw === '') return undefined;
|
|
158
177
|
if ((RETENTION_DOMAINS as readonly string[]).includes(raw)) return raw as RetentionDomain;
|
package/src/routes/reviewers.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { type ReviewerRole, ref } from '@kindgi/authz';
|
|
7
7
|
import type { Cursor, ReviewerId, TenantId, UserId } from '@kindgi/types';
|
|
8
8
|
|
|
9
9
|
import { statusFor, toWireError } from '../errors.js';
|
|
10
|
+
import type { Authorizer } from '../middleware/authorize.js';
|
|
10
11
|
import type { ReviewerRecord, ReviewerRegistryBinding } from '../reviewer-binding.js';
|
|
11
12
|
import type { AppEnv } from '../types.js';
|
|
12
13
|
import { clampLimit } from './pagination.js';
|
|
@@ -20,8 +21,9 @@ import { clampLimit } from './pagination.js';
|
|
|
20
21
|
* inside `approvalsRouter`: registering a reviewer is an admin surface
|
|
21
22
|
* action, not a reviewer-only one, so requiring a `reviewerRole` on the
|
|
22
23
|
* caller would be circular ("only reviewers can appoint reviewers").
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* With authorization enforced, registering or unregistering needs
|
|
25
|
+
* `admin` on the tenant; reading the roster needs only a valid bearer
|
|
26
|
+
* token (the `/v1/*` auth chain upstream).
|
|
25
27
|
*
|
|
26
28
|
* Mounted BEFORE `approvalsRouter` on the parent `/v1` router so path
|
|
27
29
|
* matching resolves `/v1/approvals/reviewers/*` here rather than being
|
|
@@ -29,9 +31,26 @@ import { clampLimit } from './pagination.js';
|
|
|
29
31
|
*/
|
|
30
32
|
const ROLE_VALUES: ReadonlySet<ReviewerRole> = new Set(['standard', 'senior', 'admin']);
|
|
31
33
|
|
|
32
|
-
export function reviewersRouter(
|
|
34
|
+
export function reviewersRouter(
|
|
35
|
+
binding: ReviewerRegistryBinding,
|
|
36
|
+
/**
|
|
37
|
+
* When set, registering or unregistering a reviewer needs `admin` on
|
|
38
|
+
* the tenant: a reviewer (an `admin` one above all) decides approvals.
|
|
39
|
+
* Reading the list doesn't.
|
|
40
|
+
*/
|
|
41
|
+
authorizer?: Authorizer,
|
|
42
|
+
): Hono<AppEnv> {
|
|
33
43
|
const r = new Hono<AppEnv>();
|
|
34
44
|
|
|
45
|
+
if (authorizer !== undefined) {
|
|
46
|
+
r.use('*', async (c, next) => {
|
|
47
|
+
if (c.req.method === 'GET') return next();
|
|
48
|
+
const tenantId = c.get('tenantId') as TenantId;
|
|
49
|
+
const mw = authorizer.authorize('admin', () => ref('tenant', tenantId as unknown as string));
|
|
50
|
+
return mw(c, next);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
// ---------- GET / (list, cursor-paginated) ----------
|
|
36
55
|
r.get('/', async (c) => {
|
|
37
56
|
const requestId = c.get('requestId');
|