@desplega.ai/agent-swarm 2.5.1 → 2.5.2
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/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
- package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
- package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
- package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
- package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
- package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
- package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
- package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
- package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
- package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
- package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
- package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
- package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
- package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
- package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
- package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
- package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
- package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
- package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
- package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
- package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
- package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
- package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
- package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
- package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
- package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
- package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
- package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
- package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
- package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
- package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
- package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
- package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
- package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
- package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
- package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
- package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
- package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
- package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
- package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
- package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
- package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
- package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
- package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
- package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
- package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
- package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
- package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
- package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
- package/dist/cli.js +16 -15
- package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
- package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
- package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
- package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
- package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
- package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
- package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
- package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
- package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
- package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
- package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
- package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
- package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
- package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
- package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
- package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
- package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
- package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
- package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
- package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
- package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
- package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
- package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
- package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
- package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
- package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
- package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
- package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
- package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
- package/dist/rbac-roles-452665y0.js +26 -0
- package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
- package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
- package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
- package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
- package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
- package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
- package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
- package/openapi.json +463 -3
- package/package.json +4 -3
- package/src/be/db/session-tree.ts +89 -2
- package/src/be/db/session-visibility.ts +31 -0
- package/src/be/db/tasks/read.ts +18 -5
- package/src/be/db.ts +2 -0
- package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
- package/src/be/migrations/235_chat_relay_defer.sql +86 -0
- package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
- package/src/be/rbac-roles.ts +138 -0
- package/src/be/scripts/run-credential.ts +64 -0
- package/src/be/scripts/run-saved.ts +3 -2
- package/src/be/users.ts +28 -4
- package/src/http/all-routes.ts +1 -0
- package/src/http/approval-requests.ts +46 -6
- package/src/http/index.ts +2 -0
- package/src/http/schedules.ts +26 -0
- package/src/http/scripts.ts +2 -2
- package/src/http/sessions.ts +93 -2
- package/src/http/user-roles.ts +173 -0
- package/src/http/users.ts +9 -0
- package/src/http/x.ts +2 -2
- package/src/integrations/chat-sdk/commands.ts +14 -1
- package/src/integrations/chat-sdk/inbound.ts +40 -5
- package/src/integrations/chat-sdk/outbound.ts +35 -10
- package/src/integrations/chat-sdk/reactions.ts +107 -0
- package/src/integrations/chat-sdk/relay.ts +75 -7
- package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
- package/src/integrations/reaction-events.ts +20 -0
- package/src/providers/amp-adapter.ts +1 -1
- package/src/rbac/legacy-policy.ts +3 -0
- package/src/rbac/permissions.ts +8 -0
- package/src/scheduler/schedule-task.ts +44 -3
- package/src/scheduler/scheduler.ts +4 -4
- package/src/scripts-runtime/loader.ts +8 -1
- package/src/slack/ack.ts +2 -1
- package/src/slack/reaction-shortcode.ts +4 -7
- package/src/tasks/worker-follow-up.ts +30 -0
- package/src/tests/approval-requests-notifications.test.ts +189 -0
- package/src/tests/chat-sdk-commands.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +163 -9
- package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
- package/src/tests/chat-sdk-relay.test.ts +361 -10
- package/src/tests/chat-sdk-tools.test.ts +104 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/http-users.test.ts +38 -0
- package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
- package/src/tests/rbac-engine.test.ts +2 -0
- package/src/tests/rbac-roles.test.ts +10 -0
- package/src/tests/schedule-http-triage-tools.test.ts +44 -0
- package/src/tests/script-api-credential.test.ts +173 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-run-config-scope.test.ts +120 -0
- package/src/tests/script-run-token.test.ts +75 -0
- package/src/tests/scripts-http.test.ts +19 -0
- package/src/tests/sessions-tree.test.ts +242 -0
- package/src/tests/user-roles-http.test.ts +204 -0
- package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
- package/src/tools/chat-reply.ts +2 -0
- package/src/tools/schedules/patch-schedule.ts +18 -0
- package/src/tools/schedules/update-schedule.ts +18 -0
- package/src/types.ts +16 -0
- package/src/workflows/approval-notifications.ts +214 -0
- package/src/workflows/executors/human-in-the-loop.ts +11 -172
- package/src/workflows/executors/swarm-script.ts +2 -2
- package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
- package/templates/skills/code-quality/content.md +1 -1
- package/dist/rbac-roles-ff1ffmec.js +0 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RunScriptOutput } from "../../scripts-runtime/loader";
|
|
2
2
|
import type { ScriptRecord } from "../../types";
|
|
3
3
|
import {
|
|
4
4
|
getScriptApiConnectionDescriptors,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "../script-connections";
|
|
7
7
|
import { buildScriptCredentialBindingsWithFailures } from "../script-credential-broker";
|
|
8
8
|
import { restoreScratchScriptLastUsedIfUnchanged, touchScratchScriptLastUsed } from "./db";
|
|
9
|
+
import { runScriptWithRunCredential } from "./run-credential";
|
|
9
10
|
|
|
10
11
|
export function getSavedScriptOwnerAgentId(script: ScriptRecord): string | null {
|
|
11
12
|
return script.scopeId ?? script.createdByAgentId;
|
|
@@ -45,7 +46,7 @@ export async function runSavedScriptAsAgent(
|
|
|
45
46
|
exitCode: 1,
|
|
46
47
|
error: "killed",
|
|
47
48
|
}
|
|
48
|
-
: await
|
|
49
|
+
: await runScriptWithRunCredential({
|
|
49
50
|
source: args.script.source,
|
|
50
51
|
args: args.input,
|
|
51
52
|
fsMode: args.script.fsMode,
|
package/src/be/users.ts
CHANGED
|
@@ -615,6 +615,23 @@ export async function mintRuntimeToken(
|
|
|
615
615
|
return await insertSessionToken(agentId, null, runtimeId, ttlMs);
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Mint the short-lived `aseph_` token a script run uses for its `ctx.swarm`
|
|
620
|
+
* SDK calls, so those calls present the executing agent instead of the swarm
|
|
621
|
+
* API key (the operator). It resolves as a plain agent principal (`kind:
|
|
622
|
+
* "agent"`, no task/runtime binding), so the API layer scopes it to that
|
|
623
|
+
* agent's permissions: a lead keeps lead powers, a worker reads only its own
|
|
624
|
+
* scope. Marked `scriptRun` so it stays apart from the agent's worker token
|
|
625
|
+
* (see `AGENT_TOKEN_BINDING`). The run revokes it when it ends; `ttlMs` is the
|
|
626
|
+
* backstop for a run that dies without revoking.
|
|
627
|
+
*/
|
|
628
|
+
export async function mintScriptRunToken(
|
|
629
|
+
agentId: string,
|
|
630
|
+
ttlMs: number,
|
|
631
|
+
): Promise<{ tokenId: string; plaintext: string }> {
|
|
632
|
+
return await insertSessionToken(agentId, null, null, ttlMs, null, undefined, null, null, true);
|
|
633
|
+
}
|
|
634
|
+
|
|
618
635
|
export async function insertSessionToken(
|
|
619
636
|
agentId: string,
|
|
620
637
|
taskId: string | null,
|
|
@@ -624,6 +641,7 @@ export async function insertSessionToken(
|
|
|
624
641
|
expiresAtCeiling?: string,
|
|
625
642
|
controllerId: string | null = null,
|
|
626
643
|
parentTokenId: string | null = null,
|
|
644
|
+
scriptRun = false,
|
|
627
645
|
): Promise<{ tokenId: string; plaintext: string }> {
|
|
628
646
|
const plaintext = `${SESSION_TOKEN_PREFIX}${base62(randomBytes(24))}`;
|
|
629
647
|
const tokenId = randomUUID().replace(/-/g, "");
|
|
@@ -636,8 +654,8 @@ export async function insertSessionToken(
|
|
|
636
654
|
|
|
637
655
|
await getDbClient().run(
|
|
638
656
|
`INSERT INTO session_tokens
|
|
639
|
-
(id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId)
|
|
640
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
657
|
+
(id, tokenHash, tokenPreview, agentId, taskId, runtimeId, parentRuntimeId, expiresAt, createdAt, controllerId, parentTokenId, scriptRun)
|
|
658
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
641
659
|
[
|
|
642
660
|
tokenId,
|
|
643
661
|
hash,
|
|
@@ -650,6 +668,7 @@ export async function insertSessionToken(
|
|
|
650
668
|
now.toISOString(),
|
|
651
669
|
controllerId,
|
|
652
670
|
parentTokenId,
|
|
671
|
+
scriptRun ? 1 : 0,
|
|
653
672
|
],
|
|
654
673
|
);
|
|
655
674
|
|
|
@@ -713,9 +732,14 @@ export async function revokeRuntimeTokensForRuntime(runtimeId: string): Promise<
|
|
|
713
732
|
// Agent tokens (long-lived, one agent, no runtime)
|
|
714
733
|
// ---------------------------------------------------------------------------
|
|
715
734
|
|
|
716
|
-
/**
|
|
735
|
+
/**
|
|
736
|
+
* SQL predicate for an agent (worker) token: bound to its agent and nothing
|
|
737
|
+
* else. `scriptRun = 0` excludes a script run's ephemeral token, which shares
|
|
738
|
+
* the same all-null binding shape but has its own lifecycle (see migration 233
|
|
739
|
+
* and `mintScriptRunToken`).
|
|
740
|
+
*/
|
|
717
741
|
const AGENT_TOKEN_BINDING =
|
|
718
|
-
"taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL";
|
|
742
|
+
"taskId IS NULL AND runtimeId IS NULL AND controllerId IS NULL AND parentRuntimeId IS NULL AND scriptRun = 0";
|
|
719
743
|
|
|
720
744
|
/** Stored expiry of a token that does not expire. `expiresAt` is NOT NULL. */
|
|
721
745
|
export const AGENT_TOKEN_NO_EXPIRY = "9999-12-31T23:59:59.999Z";
|
package/src/http/all-routes.ts
CHANGED
|
@@ -20,8 +20,12 @@ import { findUserById } from "../be/users";
|
|
|
20
20
|
import { can, type RbacPrincipal } from "../rbac";
|
|
21
21
|
import type { User } from "../types";
|
|
22
22
|
import { getRequestAuth } from "../utils/request-auth-context";
|
|
23
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
23
24
|
import { cancelApprovalRequest } from "../workflows/approval-cancel";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
createApprovalFollowUpTask,
|
|
27
|
+
dispatchApprovalNotifications,
|
|
28
|
+
} from "../workflows/approval-notifications";
|
|
25
29
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
26
30
|
import { bodyTask, route } from "./route-def";
|
|
27
31
|
import { jsonError } from "./utils";
|
|
@@ -288,7 +292,10 @@ const createRoute = route({
|
|
|
288
292
|
target: z.string(),
|
|
289
293
|
}),
|
|
290
294
|
)
|
|
291
|
-
.optional()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe(
|
|
297
|
+
"Where to announce the request. A Slack target (channel or user ID) gets a card with the title, the questions and a link to the dashboard page that answers it. Slack targets need a lead agent caller (403 otherwise). Email is not supported yet and is skipped.",
|
|
298
|
+
),
|
|
292
299
|
}),
|
|
293
300
|
responses: {
|
|
294
301
|
201: {
|
|
@@ -296,6 +303,7 @@ const createRoute = route({
|
|
|
296
303
|
schema: z.object({ approvalRequest: ApprovalRequestSchema }),
|
|
297
304
|
},
|
|
298
305
|
400: { description: "Validation error" },
|
|
306
|
+
403: { description: "Slack notifications require a lead agent" },
|
|
299
307
|
},
|
|
300
308
|
auth: { apiKey: true },
|
|
301
309
|
});
|
|
@@ -578,7 +586,7 @@ export async function handleApprovalRequests(
|
|
|
578
586
|
const parsed = await cancelRoute.parse(req, res, pathSegments, queryParams);
|
|
579
587
|
if (!parsed) return true;
|
|
580
588
|
|
|
581
|
-
const { principal, resolvedBy } = await
|
|
589
|
+
const { principal, resolvedBy } = await approvalCallerPrincipal(req);
|
|
582
590
|
const result = await cancelApprovalRequest({
|
|
583
591
|
id: parsed.params.id,
|
|
584
592
|
reason: parsed.body.reason,
|
|
@@ -619,6 +627,17 @@ export async function handleApprovalRequests(
|
|
|
619
627
|
const parsed = await createRoute.parse(req, res, pathSegments, queryParams);
|
|
620
628
|
if (!parsed) return true;
|
|
621
629
|
|
|
630
|
+
// Posting to Slack is lead-only everywhere else (slack-post); a caller that
|
|
631
|
+
// may not post must not reach an arbitrary channel through this route.
|
|
632
|
+
if (parsed.body.notifications?.some((notification) => notification.channel === "slack")) {
|
|
633
|
+
const { principal } = await approvalCallerPrincipal(req);
|
|
634
|
+
const decision = can({ principal, verb: "integration.slack.post", source: "http" });
|
|
635
|
+
if (!decision.allow) {
|
|
636
|
+
jsonError(res, SLACK_NOTIFICATION_REFUSED, 403);
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
622
641
|
const id = crypto.randomUUID();
|
|
623
642
|
// Prefer a trusted authenticated user (never client-controlled); else fall
|
|
624
643
|
// back to the ownership-validated sourceTaskId the request body carries.
|
|
@@ -642,6 +661,24 @@ export async function handleApprovalRequests(
|
|
|
642
661
|
createdBy,
|
|
643
662
|
});
|
|
644
663
|
|
|
664
|
+
// Fire-and-forget: Slack must not delay or fail the 201. Workflow HITL
|
|
665
|
+
// requests are created by the executor directly, so they never post here.
|
|
666
|
+
const notifications = parsed.body.notifications;
|
|
667
|
+
if (notifications?.length && request.status === "pending") {
|
|
668
|
+
dispatchApprovalNotifications({
|
|
669
|
+
requestId: request.id,
|
|
670
|
+
title: request.title,
|
|
671
|
+
questions: parsed.body.questions,
|
|
672
|
+
timeoutSeconds: parsed.body.timeoutSeconds,
|
|
673
|
+
notifications,
|
|
674
|
+
}).catch((err) => {
|
|
675
|
+
console.error(
|
|
676
|
+
"[approval-requests] Unexpected error dispatching notifications:",
|
|
677
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
645
682
|
createRoute.respond(res, 201, {
|
|
646
683
|
approvalRequest: await toApprovalRequestResponse(request),
|
|
647
684
|
});
|
|
@@ -676,11 +713,14 @@ export async function handleApprovalRequests(
|
|
|
676
713
|
return false;
|
|
677
714
|
}
|
|
678
715
|
|
|
716
|
+
const SLACK_NOTIFICATION_REFUSED =
|
|
717
|
+
"Slack notifications require lead privileges (integration.slack.post). Create the request without them, or ask the lead to post the link.";
|
|
718
|
+
|
|
679
719
|
/**
|
|
680
|
-
* The caller of the cancel
|
|
681
|
-
* header identifies that agent
|
|
720
|
+
* The caller of the create and cancel routes. The shared API key with an
|
|
721
|
+
* `X-Agent-ID` header identifies that agent.
|
|
682
722
|
*/
|
|
683
|
-
async function
|
|
723
|
+
async function approvalCallerPrincipal(
|
|
684
724
|
req: IncomingMessage,
|
|
685
725
|
): Promise<{ principal: RbacPrincipal; resolvedBy: string | null }> {
|
|
686
726
|
const auth = getRequestAuth(req);
|
package/src/http/index.ts
CHANGED
|
@@ -139,6 +139,7 @@ import { handleTasks } from "./tasks";
|
|
|
139
139
|
import { handleTelemetryUi } from "./telemetry-ui";
|
|
140
140
|
import { handleTrackers } from "./trackers";
|
|
141
141
|
import { handleUsedBy } from "./used-by";
|
|
142
|
+
import { handleUserRoles } from "./user-roles";
|
|
142
143
|
import { handleUsers } from "./users";
|
|
143
144
|
import {
|
|
144
145
|
getPathSegments,
|
|
@@ -404,6 +405,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
404
405
|
() => handleRuntimePools(req, res, pathSegments, queryParams, myAgentId),
|
|
405
406
|
() => handleExtensions(req, res, pathSegments, queryParams, myAgentId),
|
|
406
407
|
() => handleFavorites(req, res, pathSegments, queryParams, myAgentId),
|
|
408
|
+
() => handleUserRoles(req, res, pathSegments, queryParams),
|
|
407
409
|
() => handleUsers(req, res, pathSegments, queryParams),
|
|
408
410
|
() => handleSessions(req, res, pathSegments, queryParams, myAgentId),
|
|
409
411
|
() => handleInboxState(req, res, pathSegments, queryParams),
|
package/src/http/schedules.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { explicitModelErrorForAgent } from "../be/model-validation";
|
|
20
20
|
import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
|
|
21
21
|
import { getScript } from "../be/scripts/db";
|
|
22
|
+
import { can } from "../rbac";
|
|
22
23
|
import { calculateNextRun, dispatchScheduleTarget } from "../scheduler/scheduler";
|
|
23
24
|
import {
|
|
24
25
|
AgentTaskSchema,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
splitLegacyModelAlias,
|
|
31
32
|
} from "../types";
|
|
32
33
|
import { resolveHttpFavoriteOwner } from "./favorite-owner";
|
|
34
|
+
import { agentFirstPrincipal } from "./request-principal";
|
|
33
35
|
import { route } from "./route-def";
|
|
34
36
|
import { jsonError } from "./utils";
|
|
35
37
|
|
|
@@ -219,6 +221,7 @@ const updateSchedule = route({
|
|
|
219
221
|
responses: {
|
|
220
222
|
200: { description: "Schedule updated", schema: ScheduledTaskSchema.nullable() },
|
|
221
223
|
400: { description: "Validation error" },
|
|
224
|
+
403: { description: "Deferral owned by another agent" },
|
|
222
225
|
404: { description: "Schedule not found" },
|
|
223
226
|
409: { description: "Duplicate name" },
|
|
224
227
|
},
|
|
@@ -237,6 +240,7 @@ const patchSchedule = route({
|
|
|
237
240
|
responses: {
|
|
238
241
|
200: { description: "Schedule patched", schema: ScheduledTaskSchema.nullable() },
|
|
239
242
|
400: { description: "Validation error" },
|
|
243
|
+
403: { description: "Deferral owned by another agent" },
|
|
240
244
|
404: { description: "Schedule not found" },
|
|
241
245
|
409: { description: "Duplicate name" },
|
|
242
246
|
},
|
|
@@ -559,6 +563,28 @@ export async function handleSchedules(
|
|
|
559
563
|
jsonError(res, "Schedule not found", 404);
|
|
560
564
|
return true;
|
|
561
565
|
}
|
|
566
|
+
// A deferral continues its owner's task, in that task's chat thread when
|
|
567
|
+
// it has one (src/scheduler/schedule-task.ts). An agent may change it only
|
|
568
|
+
// as its owner or a lead, as in the patch-schedule and update-schedule tools.
|
|
569
|
+
if (existing.taskType === "deferred") {
|
|
570
|
+
const principal = await agentFirstPrincipal(req, myAgentId);
|
|
571
|
+
if (
|
|
572
|
+
principal.kind === "agent" &&
|
|
573
|
+
!can({
|
|
574
|
+
principal,
|
|
575
|
+
verb: "schedule.deferral.update.any",
|
|
576
|
+
resource: { kind: "owned", ownerAgentId: existing.createdByAgentId },
|
|
577
|
+
source: "http",
|
|
578
|
+
}).allow
|
|
579
|
+
) {
|
|
580
|
+
jsonError(
|
|
581
|
+
res,
|
|
582
|
+
`Schedule "${existing.name}" defers another agent's task. Only that agent or a lead can change it.`,
|
|
583
|
+
403,
|
|
584
|
+
);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
562
588
|
// A move to another agent re-judges the stored model against the new harness.
|
|
563
589
|
const agentChanged =
|
|
564
590
|
parsed.body.targetAgentId !== undefined &&
|
package/src/http/scripts.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "../be/scripts/db";
|
|
33
33
|
import { searchScripts } from "../be/scripts/embeddings";
|
|
34
34
|
import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
|
|
35
|
+
import { runScriptWithRunCredential } from "../be/scripts/run-credential";
|
|
35
36
|
import { refuseSourceWithSecrets } from "../be/scripts/source-secrets";
|
|
36
37
|
import {
|
|
37
38
|
scriptSdkTypesWithGeneratedApis,
|
|
@@ -40,7 +41,6 @@ import {
|
|
|
40
41
|
} from "../be/scripts/typecheck";
|
|
41
42
|
import { can } from "../rbac";
|
|
42
43
|
import { extractScriptSignature, type ScriptSignature } from "../scripts-runtime/extract-signature";
|
|
43
|
-
import { runScript } from "../scripts-runtime/loader";
|
|
44
44
|
import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
|
|
45
45
|
import {
|
|
46
46
|
ScriptApiAuthModeSchema,
|
|
@@ -782,7 +782,7 @@ export async function handleScripts(
|
|
|
782
782
|
? await touchScratchScriptLastUsed(namedScript.id)
|
|
783
783
|
: null;
|
|
784
784
|
const credentials = await buildScriptCredentialBindingsWithFailures({ agentId: agent.id });
|
|
785
|
-
const output = await
|
|
785
|
+
const output = await runScriptWithRunCredential({
|
|
786
786
|
source: source as string,
|
|
787
787
|
args: parsed.body.args,
|
|
788
788
|
fsMode,
|
package/src/http/sessions.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
moveSessionChains,
|
|
27
27
|
type SessionKeyMove,
|
|
28
28
|
} from "../be/db/session-moves";
|
|
29
|
-
import { getSessionReadState, getSessionTree } from "../be/db/session-tree";
|
|
29
|
+
import { getSessionReadState, getSessionTree, triageSessionList } from "../be/db/session-tree";
|
|
30
30
|
import { getUserGrant } from "../be/rbac-roles";
|
|
31
31
|
import {
|
|
32
32
|
getSessionRoot,
|
|
@@ -157,6 +157,9 @@ const SessionTreeResponseSchema = z.object({
|
|
|
157
157
|
later: SessionSectionCountsSchema,
|
|
158
158
|
done: SessionSectionCountsSchema,
|
|
159
159
|
noGroup: SessionSectionCountsSchema,
|
|
160
|
+
sources: z
|
|
161
|
+
.array(z.object({ source: z.string(), total: z.number().int() }))
|
|
162
|
+
.describe("Each root source in this view with its session count, hidden sources included."),
|
|
160
163
|
});
|
|
161
164
|
|
|
162
165
|
const SessionApprovalSchema = z.object({
|
|
@@ -480,6 +483,28 @@ async function handleAgentTokens(
|
|
|
480
483
|
return true;
|
|
481
484
|
}
|
|
482
485
|
|
|
486
|
+
const SessionShowSchema = z
|
|
487
|
+
.enum(["mine", "all"])
|
|
488
|
+
.optional()
|
|
489
|
+
.describe(
|
|
490
|
+
"Sessions v2 view, for a user viewer with tree scope. mine (default): roots the user started from a human channel (not workflow, schedule, or system) plus sessions shared with the user. all: every tree session. Lists only: access is unchanged.",
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const SessionHideSourceSchema = z
|
|
494
|
+
.string()
|
|
495
|
+
// The same cap as the `hideSource` body field of `POST /api/session-triage`.
|
|
496
|
+
.refine((value) => value.split(",").length <= 32, "hideSource takes at most 32 sources.")
|
|
497
|
+
.optional()
|
|
498
|
+
.transform((value) =>
|
|
499
|
+
value
|
|
500
|
+
?.split(",")
|
|
501
|
+
.map((source) => source.trim())
|
|
502
|
+
.filter(Boolean),
|
|
503
|
+
)
|
|
504
|
+
.describe(
|
|
505
|
+
"Comma-separated root sources to leave out (the sessions v2 source filter), e.g. `slack,chat`.",
|
|
506
|
+
);
|
|
507
|
+
|
|
483
508
|
const listSessions = route({
|
|
484
509
|
method: "get",
|
|
485
510
|
path: "/api/sessions",
|
|
@@ -539,6 +564,10 @@ const listSessions = route({
|
|
|
539
564
|
.describe(
|
|
540
565
|
"mine: owned sessions. shared: sessions shared with me. tree: Sessions that GET /api/session-tree counts: mine plus shared with me, no ownerless roots. A bypass actor sees every root with tree scope.",
|
|
541
566
|
),
|
|
567
|
+
/** With `scope=tree`: `mine` (default) or `all`. Must match the tree's `show`. */
|
|
568
|
+
show: SessionShowSchema,
|
|
569
|
+
/** Root sources to leave out. Must match the tree's `hideSource`. */
|
|
570
|
+
hideSource: SessionHideSourceSchema,
|
|
542
571
|
}),
|
|
543
572
|
responses: {
|
|
544
573
|
200: {
|
|
@@ -561,6 +590,7 @@ const sessionTree = route({
|
|
|
561
590
|
pattern: ["api", "session-tree"],
|
|
562
591
|
summary: "Get session groups and triage counts",
|
|
563
592
|
tags: ["Sessions"],
|
|
593
|
+
query: z.object({ show: SessionShowSchema, hideSource: SessionHideSourceSchema }),
|
|
564
594
|
responses: {
|
|
565
595
|
200: { description: "Visible groups and section counts", schema: SessionTreeResponseSchema },
|
|
566
596
|
401: { description: "Unauthorized" },
|
|
@@ -568,6 +598,39 @@ const sessionTree = route({
|
|
|
568
598
|
auth: { apiKey: true },
|
|
569
599
|
});
|
|
570
600
|
|
|
601
|
+
const triageSessionListRoute = route({
|
|
602
|
+
method: "post",
|
|
603
|
+
path: "/api/session-triage",
|
|
604
|
+
pattern: ["api", "session-triage"],
|
|
605
|
+
summary: "Triage every session of one sidebar list",
|
|
606
|
+
description:
|
|
607
|
+
"The bulk bar's 'Select all N': Done, back to the Inbox, read, or unread for every session that one sessions v2 list shows the caller (the `GET /api/sessions?scope=tree` filters). Needs a picked user.",
|
|
608
|
+
tags: ["Sessions"],
|
|
609
|
+
body: z
|
|
610
|
+
.object({
|
|
611
|
+
action: z.enum(["done", "inbox", "read", "unread"]),
|
|
612
|
+
/** The list: one of Inbox, Later, Done, No group, or a group. */
|
|
613
|
+
triage: z.enum(["inbox", "later", "done"]).optional(),
|
|
614
|
+
noGroup: z.boolean().optional(),
|
|
615
|
+
groupKey: SessionKeyFilterSchema.optional(),
|
|
616
|
+
show: SessionShowSchema,
|
|
617
|
+
hideSource: z.array(z.string().min(1)).max(32).optional(),
|
|
618
|
+
})
|
|
619
|
+
.refine((body) => [body.triage, body.noGroup, body.groupKey].filter(Boolean).length === 1, {
|
|
620
|
+
message: "Name exactly one list: triage, noGroup, or groupKey",
|
|
621
|
+
}),
|
|
622
|
+
responses: {
|
|
623
|
+
200: {
|
|
624
|
+
description: "The number of sessions written",
|
|
625
|
+
schema: z.object({ updated: z.number().int() }),
|
|
626
|
+
},
|
|
627
|
+
400: { description: "Validation error, or no picked user" },
|
|
628
|
+
401: { description: "Unauthorized" },
|
|
629
|
+
},
|
|
630
|
+
auth: { apiKey: true },
|
|
631
|
+
rbac: { ungated: "per-user inbox UI state, same posture as PATCH /api/inbox-state" },
|
|
632
|
+
});
|
|
633
|
+
|
|
571
634
|
const SESSION_MOVE_LIMIT = 200;
|
|
572
635
|
const SESSION_MOVE_OWNER_ONLY = "Only the session owner can move this group";
|
|
573
636
|
const SESSION_MOVE_UNAVAILABLE = "Session not found or not accessible";
|
|
@@ -877,6 +940,7 @@ export async function handleSessions(
|
|
|
877
940
|
}
|
|
878
941
|
const filterOpts = {
|
|
879
942
|
source: sources,
|
|
943
|
+
hideSource: parsed.query.hideSource,
|
|
880
944
|
q: parsed.query.q,
|
|
881
945
|
requestedByUserId: parsed.query.requestedByUserId,
|
|
882
946
|
contextKeyPrefix: parsed.query.contextKeyPrefix,
|
|
@@ -888,6 +952,7 @@ export async function handleSessions(
|
|
|
888
952
|
withUserId: parsed.query.withUserId,
|
|
889
953
|
now: new Date().toISOString(),
|
|
890
954
|
tree: parsed.query.scope === "tree",
|
|
955
|
+
mine: parsed.query.scope === "tree" && parsed.query.show !== "all",
|
|
891
956
|
...(actor.kind === "user"
|
|
892
957
|
? {
|
|
893
958
|
viewer: {
|
|
@@ -922,7 +987,33 @@ export async function handleSessions(
|
|
|
922
987
|
if (sessionTree.match(req.method, pathSegments)) {
|
|
923
988
|
const parsed = await sessionTree.parse(req, res, pathSegments, queryParams);
|
|
924
989
|
if (!parsed) return true;
|
|
925
|
-
|
|
990
|
+
const actor = await resolveSessionActor(req, myAgentId);
|
|
991
|
+
sessionTree.respond(
|
|
992
|
+
res,
|
|
993
|
+
200,
|
|
994
|
+
await getSessionTree(actor, undefined, {
|
|
995
|
+
mine: parsed.query.show !== "all",
|
|
996
|
+
hideSource: parsed.query.hideSource,
|
|
997
|
+
}),
|
|
998
|
+
);
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (triageSessionListRoute.match(req.method, pathSegments)) {
|
|
1003
|
+
const parsed = await triageSessionListRoute.parse(req, res, pathSegments, queryParams);
|
|
1004
|
+
if (!parsed) return true;
|
|
1005
|
+
const actor = await resolveSessionActor(req, myAgentId);
|
|
1006
|
+
if (actor.kind !== "user" || !actor.userId) {
|
|
1007
|
+
jsonError(res, "Pick a user to triage sessions", 400);
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
const { action, show, ...list } = parsed.body;
|
|
1011
|
+
const updated = await triageSessionList(
|
|
1012
|
+
actor.userId,
|
|
1013
|
+
{ ...list, mine: show !== "all" },
|
|
1014
|
+
action,
|
|
1015
|
+
);
|
|
1016
|
+
triageSessionListRoute.respond(res, 200, { updated });
|
|
926
1017
|
return true;
|
|
927
1018
|
}
|
|
928
1019
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RBAC role assignment for people.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/rbac/roles
|
|
5
|
+
* GET /api/users/:id/roles
|
|
6
|
+
* PUT /api/users/:id/roles
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { getUserById } from "../be/db";
|
|
12
|
+
import {
|
|
13
|
+
getUserGrant,
|
|
14
|
+
InvalidRoleSetError,
|
|
15
|
+
LastAdminError,
|
|
16
|
+
listRbacRoles,
|
|
17
|
+
listUserRoles,
|
|
18
|
+
PRIMARY_ROLE_NAMES,
|
|
19
|
+
setUserRoles,
|
|
20
|
+
} from "../be/rbac-roles";
|
|
21
|
+
import { recordIdentityEvent } from "../be/users";
|
|
22
|
+
import { can, isRbacEnabled } from "../rbac";
|
|
23
|
+
import { RbacRoleSchema, UserRbacRolesSchema } from "../types";
|
|
24
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
25
|
+
import { getOperatorActor } from "./operator-actor";
|
|
26
|
+
import { agentFirstPrincipal, rejectGuest } from "./request-principal";
|
|
27
|
+
import { route } from "./route-def";
|
|
28
|
+
import { jsonError } from "./utils";
|
|
29
|
+
|
|
30
|
+
const listRolesRoute = route({
|
|
31
|
+
method: "get",
|
|
32
|
+
path: "/api/rbac/roles",
|
|
33
|
+
pattern: ["api", "rbac", "roles"],
|
|
34
|
+
operationId: "rbac_listRoles",
|
|
35
|
+
summary: "List RBAC roles with their verbs",
|
|
36
|
+
tags: ["Users"],
|
|
37
|
+
responses: {
|
|
38
|
+
200: { description: "Role catalog", schema: z.object({ roles: z.array(RbacRoleSchema) }) },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const getUserRolesRoute = route({
|
|
43
|
+
method: "get",
|
|
44
|
+
path: "/api/users/{id}/roles",
|
|
45
|
+
pattern: ["api", "users", null, "roles"],
|
|
46
|
+
operationId: "users_getRbacRoles",
|
|
47
|
+
summary: "Get a user's RBAC roles",
|
|
48
|
+
tags: ["Users"],
|
|
49
|
+
params: z.object({ id: z.string().min(1) }),
|
|
50
|
+
responses: {
|
|
51
|
+
200: { description: "The user's roles", schema: UserRbacRolesSchema },
|
|
52
|
+
404: { description: "User not found" },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const setUserRolesRoute = route({
|
|
57
|
+
method: "put",
|
|
58
|
+
path: "/api/users/{id}/roles",
|
|
59
|
+
pattern: ["api", "users", null, "roles"],
|
|
60
|
+
operationId: "users_setRbacRoles",
|
|
61
|
+
summary: "Set a user's RBAC roles",
|
|
62
|
+
description: `Replaces the user's roles. The set holds exactly one of ${PRIMARY_ROLE_NAMES.join(", ")} plus any extra roles. Demoting a user below admin removes the ability to answer approval requests, even when the user is listed as an approver. Fails with 409 when it would leave no active admin.`,
|
|
63
|
+
tags: ["Users"],
|
|
64
|
+
params: z.object({ id: z.string().min(1) }),
|
|
65
|
+
body: z.object({ roles: z.array(z.string().min(1)).min(1) }).strict(),
|
|
66
|
+
responses: {
|
|
67
|
+
200: { description: "The user's roles after the change", schema: UserRbacRolesSchema },
|
|
68
|
+
400: { description: "Invalid role set" },
|
|
69
|
+
403: { description: "Not allowed to change roles" },
|
|
70
|
+
404: { description: "User not found" },
|
|
71
|
+
409: { description: "Would leave no active admin" },
|
|
72
|
+
},
|
|
73
|
+
rbac: { permission: "user.role.manage" },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function composeRoles(userId: string) {
|
|
77
|
+
const [catalog, held] = await Promise.all([listRbacRoles(), listUserRoles(userId)]);
|
|
78
|
+
const names = new Set(held.map((role) => role.name));
|
|
79
|
+
return { userId, roles: catalog.filter((role) => names.has(role.name)) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Operator or user only; agents never change human roles, even with the shared key. */
|
|
83
|
+
async function ensureRoleManager(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
84
|
+
if (rejectGuest(req, res)) return false;
|
|
85
|
+
const auth = getRequestAuth(req);
|
|
86
|
+
if (auth?.kind !== "operator" && auth?.kind !== "user") {
|
|
87
|
+
jsonError(res, "Role changes require operator or user credentials", 403);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const header = req.headers["x-agent-id"];
|
|
91
|
+
const decision = can({
|
|
92
|
+
principal: await agentFirstPrincipal(req, Array.isArray(header) ? header[0] : header),
|
|
93
|
+
verb: "user.role.manage",
|
|
94
|
+
resource: { kind: "none" },
|
|
95
|
+
source: "http",
|
|
96
|
+
});
|
|
97
|
+
if (!decision.allow) {
|
|
98
|
+
jsonError(res, decision.reason, 403);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
// A custom role holding user.role.manage must not be able to grant admin.
|
|
102
|
+
if (auth.kind === "user" && isRbacEnabled() && !(await getUserGrant(auth.userId)).grantsAll) {
|
|
103
|
+
jsonError(res, "Role changes require a grant-all role", 403);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function handleUserRoles(
|
|
110
|
+
req: IncomingMessage,
|
|
111
|
+
res: ServerResponse,
|
|
112
|
+
pathSegments: string[],
|
|
113
|
+
queryParams: URLSearchParams,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
if (listRolesRoute.match(req.method, pathSegments)) {
|
|
116
|
+
if (rejectGuest(req, res)) return true;
|
|
117
|
+
listRolesRoute.respond(res, 200, { roles: await listRbacRoles() });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (getUserRolesRoute.match(req.method, pathSegments)) {
|
|
122
|
+
if (rejectGuest(req, res)) return true;
|
|
123
|
+
const parsed = await getUserRolesRoute.parse(req, res, pathSegments, queryParams);
|
|
124
|
+
if (!parsed) return true;
|
|
125
|
+
if (!(await getUserById(parsed.params.id))) {
|
|
126
|
+
jsonError(res, "User not found", 404);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
getUserRolesRoute.respond(res, 200, await composeRoles(parsed.params.id));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (setUserRolesRoute.match(req.method, pathSegments)) {
|
|
134
|
+
const parsed = await setUserRolesRoute.parse(req, res, pathSegments, queryParams);
|
|
135
|
+
if (!parsed) return true;
|
|
136
|
+
if (!(await ensureRoleManager(req, res))) return true;
|
|
137
|
+
const actor = getOperatorActor(req, res);
|
|
138
|
+
if (!actor) return true;
|
|
139
|
+
const userId = parsed.params.id;
|
|
140
|
+
if (!(await getUserById(userId))) {
|
|
141
|
+
jsonError(res, "User not found", 404);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const { before, after } = await setUserRoles(
|
|
146
|
+
userId,
|
|
147
|
+
parsed.body.roles,
|
|
148
|
+
actor.kind === "user" ? actor.id : null,
|
|
149
|
+
);
|
|
150
|
+
await recordIdentityEvent(
|
|
151
|
+
userId,
|
|
152
|
+
"profile_changed",
|
|
153
|
+
actor,
|
|
154
|
+
{ rbacRoles: before },
|
|
155
|
+
{ rbacRoles: after },
|
|
156
|
+
);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err instanceof LastAdminError) {
|
|
159
|
+
jsonError(res, "last_admin", 409);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (err instanceof InvalidRoleSetError) {
|
|
163
|
+
jsonError(res, err.message, 400);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
setUserRolesRoute.respond(res, 200, await composeRoles(userId));
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return false;
|
|
173
|
+
}
|
package/src/http/users.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
updateUser,
|
|
46
46
|
upsertBudget,
|
|
47
47
|
} from "../be/db";
|
|
48
|
+
import { assertNotLastAdmin, LastAdminError } from "../be/rbac-roles";
|
|
48
49
|
import {
|
|
49
50
|
getUserIdentities,
|
|
50
51
|
type IdentityEvent,
|
|
@@ -484,6 +485,7 @@ const mergeUsersRoute = route({
|
|
|
484
485
|
400: { description: "Validation error (e.g. target == source)" },
|
|
485
486
|
401: { description: "Unauthorized" },
|
|
486
487
|
404: { description: "Target or source user not found" },
|
|
488
|
+
409: { description: "Source is the last active admin (last_admin)" },
|
|
487
489
|
},
|
|
488
490
|
auth: { apiKey: true },
|
|
489
491
|
});
|
|
@@ -1071,6 +1073,9 @@ export async function handleUsers(
|
|
|
1071
1073
|
}
|
|
1072
1074
|
}
|
|
1073
1075
|
|
|
1076
|
+
// The source's roles go with it; never leave the swarm without an admin.
|
|
1077
|
+
await assertNotLastAdmin(sourceId);
|
|
1078
|
+
|
|
1074
1079
|
// Delete source — CASCADE cleans up any leftover external_ids row that
|
|
1075
1080
|
// we may have missed. Retained user references move to the target.
|
|
1076
1081
|
await deleteUser(sourceId, targetId);
|
|
@@ -1095,6 +1100,10 @@ export async function handleUsers(
|
|
|
1095
1100
|
});
|
|
1096
1101
|
mergeUsersRoute.respond(res, 200, { user: mergedUser });
|
|
1097
1102
|
} catch (err) {
|
|
1103
|
+
if (err instanceof LastAdminError) {
|
|
1104
|
+
jsonError(res, "last_admin", 409);
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1098
1107
|
jsonError(res, err instanceof Error ? err.message : "Failed to merge users", 500);
|
|
1099
1108
|
}
|
|
1100
1109
|
return true;
|