@agent-native/dispatch 0.14.1 → 0.14.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/README.md +6 -0
- package/dist/actions/approve-dispatch-change.d.ts +10 -10
- package/dist/actions/provider-api-register.js +64 -2
- package/dist/actions/provider-api-register.js.map +1 -1
- package/dist/actions/reject-dispatch-change.d.ts +10 -10
- package/dist/components/messaging-setup-panel.d.ts.map +1 -1
- package/dist/components/messaging-setup-panel.js +15 -2
- package/dist/components/messaging-setup-panel.js.map +1 -1
- package/dist/server/lib/dispatch-store.d.ts +24 -21
- package/dist/server/lib/dispatch-store.d.ts.map +1 -1
- package/dist/server/lib/dispatch-store.js +59 -23
- package/dist/server/lib/dispatch-store.js.map +1 -1
- package/dist/server/lib/env-config.js +1 -1
- package/dist/server/lib/env-config.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +102 -37
- package/dist/server/lib/vault-store.js.map +1 -1
- package/package.json +2 -3
- package/src/actions/provider-api-register.spec.ts +193 -0
- package/src/actions/provider-api-register.ts +66 -1
- package/src/components/messaging-setup-panel.spec.tsx +17 -1
- package/src/components/messaging-setup-panel.tsx +81 -1
- package/src/server/lib/approval-fencing.spec.ts +309 -0
- package/src/server/lib/dispatch-store.ts +92 -21
- package/src/server/lib/env-config.ts +1 -1
- package/src/server/lib/vault-store.ts +151 -45
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
|
|
3
|
+
import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
|
|
4
4
|
import {
|
|
5
5
|
getRequestUserEmail,
|
|
6
6
|
getRequestOrgId,
|
|
@@ -11,6 +11,7 @@ import { getDb, schema } from "../../db/index.js";
|
|
|
11
11
|
|
|
12
12
|
export const SHARED_DISPATCH_OWNER = "dispatch@shared";
|
|
13
13
|
const APPROVAL_POLICY_KEY = "dispatch-approval-policy";
|
|
14
|
+
const APPROVAL_APPLY_LEASE_MS = 5 * 60 * 1000;
|
|
14
15
|
|
|
15
16
|
// ─── /link rate limiting ──────────────────────────────────────────────────
|
|
16
17
|
//
|
|
@@ -188,8 +189,8 @@ export async function getApprovalPolicy(): Promise<DispatchApprovalPolicy> {
|
|
|
188
189
|
async function applyApprovalPolicy(
|
|
189
190
|
input: DispatchApprovalPolicy,
|
|
190
191
|
actor = currentOwnerEmail(),
|
|
192
|
+
orgId = currentOrgId(),
|
|
191
193
|
) {
|
|
192
|
-
const orgId = currentOrgId();
|
|
193
194
|
if (!orgId) {
|
|
194
195
|
throw new Error(
|
|
195
196
|
"Dispatch approval settings require an active organization",
|
|
@@ -209,7 +210,10 @@ async function applyApprovalPolicy(
|
|
|
209
210
|
metadata: input,
|
|
210
211
|
actor,
|
|
211
212
|
});
|
|
212
|
-
return
|
|
213
|
+
return {
|
|
214
|
+
enabled: input.enabled,
|
|
215
|
+
approverEmails: input.approverEmails,
|
|
216
|
+
};
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
export async function setApprovalPolicy(input: DispatchApprovalPolicy) {
|
|
@@ -564,6 +568,7 @@ async function applyApprovedRequest(request: DispatchApprovalRequest) {
|
|
|
564
568
|
return applyApprovalPolicy(
|
|
565
569
|
payload,
|
|
566
570
|
request.reviewedBy || currentOwnerEmail(),
|
|
571
|
+
requestCtx.orgId,
|
|
567
572
|
);
|
|
568
573
|
}
|
|
569
574
|
if (request.changeType === "dream-proposal.apply") {
|
|
@@ -610,22 +615,77 @@ export async function approveRequest(requestId: string) {
|
|
|
610
615
|
const ctx = requireDispatchCtx();
|
|
611
616
|
const request = await getApprovalRequest(requestId, ctx);
|
|
612
617
|
if (!request) throw new Error("Approval request not found");
|
|
613
|
-
|
|
614
|
-
throw new Error("Only pending approvals can be approved");
|
|
615
|
-
}
|
|
618
|
+
|
|
616
619
|
const timestamp = now();
|
|
617
|
-
|
|
620
|
+
const staleApplyingBefore = timestamp - APPROVAL_APPLY_LEASE_MS;
|
|
621
|
+
// Fence the transition on the current status so a concurrent approve can't
|
|
622
|
+
// both win. A crashed worker can leave its lease in "applying", so reclaim
|
|
623
|
+
// only a lease that has been idle for the full lease interval.
|
|
624
|
+
const claimed = await db
|
|
625
|
+
.update(schema.dispatchApprovalRequests)
|
|
626
|
+
.set({
|
|
627
|
+
status: "applying",
|
|
628
|
+
updatedAt: timestamp,
|
|
629
|
+
})
|
|
630
|
+
.where(
|
|
631
|
+
and(
|
|
632
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
633
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
634
|
+
or(
|
|
635
|
+
eq(schema.dispatchApprovalRequests.status, "pending"),
|
|
636
|
+
and(
|
|
637
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
638
|
+
sql`${schema.dispatchApprovalRequests.updatedAt} < ${staleApplyingBefore}`,
|
|
639
|
+
),
|
|
640
|
+
),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
.returning();
|
|
644
|
+
|
|
645
|
+
if (claimed.length === 0) {
|
|
646
|
+
const current = (await getApprovalRequest(requestId, ctx)) ?? request;
|
|
647
|
+
if (current.status === "applying") {
|
|
648
|
+
throw new Error("Approval is already being applied");
|
|
649
|
+
}
|
|
650
|
+
return current;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const applying = claimed[0];
|
|
654
|
+
try {
|
|
655
|
+
await applyApprovedRequest(applying);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
// Applying a request can have succeeded partially before throwing. Do not
|
|
658
|
+
// return it to pending: that would allow an immediate retry to duplicate a
|
|
659
|
+
// non-idempotent effect. Keep the lease until it becomes stale, which
|
|
660
|
+
// serializes any recovery attempt behind the same fencing rule.
|
|
661
|
+
await db
|
|
662
|
+
.update(schema.dispatchApprovalRequests)
|
|
663
|
+
.set({ updatedAt: now() })
|
|
664
|
+
.where(
|
|
665
|
+
and(
|
|
666
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
667
|
+
ctxScope(schema.dispatchApprovalRequests, ctx),
|
|
668
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
const [updated] = await db
|
|
618
674
|
.update(schema.dispatchApprovalRequests)
|
|
619
675
|
.set({
|
|
620
676
|
status: "approved",
|
|
621
677
|
reviewedBy: currentOwnerEmail(),
|
|
622
678
|
reviewedAt: timestamp,
|
|
623
|
-
updatedAt:
|
|
679
|
+
updatedAt: now(),
|
|
624
680
|
})
|
|
625
|
-
.where(
|
|
626
|
-
|
|
681
|
+
.where(
|
|
682
|
+
and(
|
|
683
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
684
|
+
eq(schema.dispatchApprovalRequests.status, "applying"),
|
|
685
|
+
),
|
|
686
|
+
)
|
|
687
|
+
.returning();
|
|
627
688
|
if (!updated) throw new Error("Approval request disappeared");
|
|
628
|
-
await applyApprovedRequest(updated);
|
|
629
689
|
await recordAudit({
|
|
630
690
|
action: "approval.approved",
|
|
631
691
|
targetType: updated.targetType,
|
|
@@ -638,13 +698,12 @@ export async function approveRequest(requestId: string) {
|
|
|
638
698
|
|
|
639
699
|
export async function rejectRequest(requestId: string, reason?: string | null) {
|
|
640
700
|
const db = getDb();
|
|
641
|
-
const
|
|
701
|
+
const ctx = requireDispatchCtx();
|
|
702
|
+
const request = await getApprovalRequest(requestId, ctx);
|
|
642
703
|
if (!request) throw new Error("Approval request not found");
|
|
643
|
-
|
|
644
|
-
throw new Error("Only pending approvals can be rejected");
|
|
645
|
-
}
|
|
704
|
+
|
|
646
705
|
const timestamp = now();
|
|
647
|
-
await db
|
|
706
|
+
const claimed = await db
|
|
648
707
|
.update(schema.dispatchApprovalRequests)
|
|
649
708
|
.set({
|
|
650
709
|
status: "rejected",
|
|
@@ -652,15 +711,27 @@ export async function rejectRequest(requestId: string, reason?: string | null) {
|
|
|
652
711
|
reviewedAt: timestamp,
|
|
653
712
|
updatedAt: timestamp,
|
|
654
713
|
})
|
|
655
|
-
.where(
|
|
714
|
+
.where(
|
|
715
|
+
and(
|
|
716
|
+
eq(schema.dispatchApprovalRequests.id, requestId),
|
|
717
|
+
eq(schema.dispatchApprovalRequests.status, "pending"),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
.returning();
|
|
721
|
+
|
|
722
|
+
if (claimed.length === 0) {
|
|
723
|
+
return (await getApprovalRequest(requestId, ctx)) ?? request;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const updated = claimed[0];
|
|
656
727
|
await recordAudit({
|
|
657
728
|
action: "approval.rejected",
|
|
658
|
-
targetType:
|
|
729
|
+
targetType: updated.targetType,
|
|
659
730
|
targetId: requestId,
|
|
660
|
-
summary: `Rejected ${
|
|
661
|
-
metadata: { request, reason: reason || null },
|
|
731
|
+
summary: `Rejected ${updated.summary}`,
|
|
732
|
+
metadata: { request: updated, reason: reason || null },
|
|
662
733
|
});
|
|
663
|
-
return
|
|
734
|
+
return updated;
|
|
664
735
|
}
|
|
665
736
|
|
|
666
737
|
export async function createLinkToken(platform: string) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
|
|
3
|
+
import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
|
|
4
4
|
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
|
|
5
5
|
import {
|
|
6
6
|
deleteAppSecret,
|
|
@@ -567,8 +567,59 @@ export async function createGrant(
|
|
|
567
567
|
if (!secret) throw new Error("Secret not found");
|
|
568
568
|
|
|
569
569
|
const timestamp = now();
|
|
570
|
-
const grantId = id();
|
|
571
570
|
const actor = ctx.ownerEmail;
|
|
571
|
+
const [existing] = await db
|
|
572
|
+
.select()
|
|
573
|
+
.from(schema.vaultGrants)
|
|
574
|
+
.where(
|
|
575
|
+
and(
|
|
576
|
+
eq(schema.vaultGrants.secretId, secretId),
|
|
577
|
+
eq(schema.vaultGrants.appId, appId),
|
|
578
|
+
ctxScope(schema.vaultGrants, ctx),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
.orderBy(desc(schema.vaultGrants.updatedAt))
|
|
582
|
+
.limit(1);
|
|
583
|
+
|
|
584
|
+
if (existing?.status === "active") {
|
|
585
|
+
return existing;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (existing) {
|
|
589
|
+
await db
|
|
590
|
+
.update(schema.vaultGrants)
|
|
591
|
+
.set({
|
|
592
|
+
grantedBy: actor,
|
|
593
|
+
status: "active",
|
|
594
|
+
syncedAt: null,
|
|
595
|
+
updatedAt: timestamp,
|
|
596
|
+
})
|
|
597
|
+
.where(
|
|
598
|
+
and(
|
|
599
|
+
eq(schema.vaultGrants.id, existing.id),
|
|
600
|
+
ctxScope(schema.vaultGrants, ctx),
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
await recordVaultAudit({
|
|
605
|
+
action: "grant.reinstated",
|
|
606
|
+
secretId,
|
|
607
|
+
appId,
|
|
608
|
+
summary: `Reinstated "${secret.name}" (${secret.credentialKey}) for ${appId}`,
|
|
609
|
+
metadata: { grantId: existing.id },
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
await recordAudit({
|
|
613
|
+
action: "vault.grant.reinstated",
|
|
614
|
+
targetType: "vault-grant",
|
|
615
|
+
targetId: existing.id,
|
|
616
|
+
summary: `Reinstated vault secret "${secret.name}" for ${appId}`,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
return getGrant(existing.id, ctx);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const grantId = id();
|
|
572
623
|
|
|
573
624
|
await db.insert(schema.vaultGrants).values({
|
|
574
625
|
id: grantId,
|
|
@@ -956,65 +1007,115 @@ export async function approveRequest(
|
|
|
956
1007
|
const db = getDb();
|
|
957
1008
|
const request = await getRequest(requestId, ctx);
|
|
958
1009
|
if (!request) throw new Error("Request not found");
|
|
959
|
-
if (request.status !== "pending") {
|
|
960
|
-
throw new Error("Only pending requests can be approved");
|
|
961
|
-
}
|
|
962
1010
|
|
|
963
1011
|
const timestamp = now();
|
|
964
1012
|
const reviewer = ctx.ownerEmail;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1013
|
+
const staleApplyingBefore = timestamp - 5 * 60 * 1000;
|
|
1014
|
+
|
|
1015
|
+
// Fence the transition on the current status — scoped to caller's tenant —
|
|
1016
|
+
// so a concurrent approve can't both win: only the caller that flips
|
|
1017
|
+
// pending -> applying proceeds to create the secret/grant. A crashed worker
|
|
1018
|
+
// leaves an applying row behind, so a later reviewer may reclaim a lease
|
|
1019
|
+
// that has been idle for five minutes. See
|
|
1020
|
+
// claimAgentTeamRun in packages/core/src/server/agent-teams-run-queue.ts
|
|
1021
|
+
// for the same pattern.
|
|
1022
|
+
const claimed = await db
|
|
968
1023
|
.update(schema.vaultRequests)
|
|
969
1024
|
.set({
|
|
970
|
-
status: "
|
|
971
|
-
reviewedBy: reviewer,
|
|
972
|
-
reviewedAt: timestamp,
|
|
1025
|
+
status: "applying",
|
|
973
1026
|
updatedAt: timestamp,
|
|
974
1027
|
})
|
|
975
1028
|
.where(
|
|
976
1029
|
and(
|
|
977
1030
|
eq(schema.vaultRequests.id, requestId),
|
|
978
1031
|
ctxScope(schema.vaultRequests, ctx),
|
|
1032
|
+
or(
|
|
1033
|
+
eq(schema.vaultRequests.status, "pending"),
|
|
1034
|
+
and(
|
|
1035
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
1036
|
+
sql`${schema.vaultRequests.updatedAt} < ${staleApplyingBefore}`,
|
|
1037
|
+
),
|
|
1038
|
+
),
|
|
979
1039
|
),
|
|
980
|
-
)
|
|
1040
|
+
)
|
|
1041
|
+
.returning();
|
|
1042
|
+
|
|
1043
|
+
if (claimed.length === 0) {
|
|
1044
|
+
const current = await getRequest(requestId, ctx);
|
|
1045
|
+
if (current?.status === "applying") {
|
|
1046
|
+
throw new Error("Vault request is already being applied");
|
|
1047
|
+
}
|
|
1048
|
+
return current;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const claimedRequest = claimed[0];
|
|
981
1052
|
|
|
982
1053
|
// Secret + grant must land in the REQUEST's tenant, not the approver's
|
|
983
1054
|
// (the approver may be acting on behalf of another user in the same org).
|
|
984
|
-
const requestCtx = ctxForRow(
|
|
1055
|
+
const requestCtx = ctxForRow(claimedRequest);
|
|
985
1056
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1057
|
+
try {
|
|
1058
|
+
// Check if secret already exists in the request's tenant for this key.
|
|
1059
|
+
const existingSecrets = await db
|
|
1060
|
+
.select()
|
|
1061
|
+
.from(schema.vaultSecrets)
|
|
1062
|
+
.where(
|
|
1063
|
+
and(
|
|
1064
|
+
eq(schema.vaultSecrets.credentialKey, claimedRequest.credentialKey),
|
|
1065
|
+
ctxScope(schema.vaultSecrets, requestCtx),
|
|
1066
|
+
),
|
|
1067
|
+
);
|
|
1068
|
+
let secret = existingSecrets[0] ?? null;
|
|
1069
|
+
|
|
1070
|
+
if (!secret) {
|
|
1071
|
+
secret = await createSecret(
|
|
1072
|
+
{
|
|
1073
|
+
credentialKey: claimedRequest.credentialKey,
|
|
1074
|
+
value: secretValue,
|
|
1075
|
+
name: secretName || claimedRequest.credentialKey,
|
|
1076
|
+
},
|
|
1077
|
+
requestCtx,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (secret) {
|
|
1082
|
+
// Create the grant in the request's tenant as well.
|
|
1083
|
+
await createGrant(secret.id, claimedRequest.appId, requestCtx);
|
|
1084
|
+
}
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
await db
|
|
1087
|
+
.update(schema.vaultRequests)
|
|
1088
|
+
.set({ status: "pending", updatedAt: now() })
|
|
1089
|
+
.where(
|
|
1090
|
+
and(
|
|
1091
|
+
eq(schema.vaultRequests.id, requestId),
|
|
1092
|
+
ctxScope(schema.vaultRequests, ctx),
|
|
1093
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
await db
|
|
1100
|
+
.update(schema.vaultRequests)
|
|
1101
|
+
.set({
|
|
1102
|
+
status: "approved",
|
|
1103
|
+
reviewedBy: reviewer,
|
|
1104
|
+
reviewedAt: timestamp,
|
|
1105
|
+
updatedAt: now(),
|
|
1106
|
+
})
|
|
990
1107
|
.where(
|
|
991
1108
|
and(
|
|
992
|
-
eq(schema.
|
|
993
|
-
ctxScope(schema.
|
|
1109
|
+
eq(schema.vaultRequests.id, requestId),
|
|
1110
|
+
ctxScope(schema.vaultRequests, ctx),
|
|
1111
|
+
eq(schema.vaultRequests.status, "applying"),
|
|
994
1112
|
),
|
|
995
1113
|
);
|
|
996
|
-
let secret = existingSecrets[0] ?? null;
|
|
997
|
-
|
|
998
|
-
if (!secret) {
|
|
999
|
-
secret = await createSecret(
|
|
1000
|
-
{
|
|
1001
|
-
credentialKey: request.credentialKey,
|
|
1002
|
-
value: secretValue,
|
|
1003
|
-
name: secretName || request.credentialKey,
|
|
1004
|
-
},
|
|
1005
|
-
requestCtx,
|
|
1006
|
-
);
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (secret) {
|
|
1010
|
-
// Create the grant in the request's tenant as well.
|
|
1011
|
-
await createGrant(secret.id, request.appId, requestCtx);
|
|
1012
|
-
}
|
|
1013
1114
|
|
|
1014
1115
|
await recordVaultAudit({
|
|
1015
1116
|
action: "request.approved",
|
|
1016
|
-
appId:
|
|
1017
|
-
summary: `Approved ${
|
|
1117
|
+
appId: claimedRequest.appId,
|
|
1118
|
+
summary: `Approved ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
|
|
1018
1119
|
metadata: { requestId, reviewer },
|
|
1019
1120
|
});
|
|
1020
1121
|
|
|
@@ -1029,14 +1130,11 @@ export async function denyRequest(
|
|
|
1029
1130
|
const db = getDb();
|
|
1030
1131
|
const request = await getRequest(requestId, ctx);
|
|
1031
1132
|
if (!request) throw new Error("Request not found");
|
|
1032
|
-
if (request.status !== "pending") {
|
|
1033
|
-
throw new Error("Only pending requests can be denied");
|
|
1034
|
-
}
|
|
1035
1133
|
|
|
1036
1134
|
const timestamp = now();
|
|
1037
1135
|
const reviewer = ctx.ownerEmail;
|
|
1038
1136
|
|
|
1039
|
-
await db
|
|
1137
|
+
const claimed = await db
|
|
1040
1138
|
.update(schema.vaultRequests)
|
|
1041
1139
|
.set({
|
|
1042
1140
|
status: "denied",
|
|
@@ -1048,13 +1146,21 @@ export async function denyRequest(
|
|
|
1048
1146
|
and(
|
|
1049
1147
|
eq(schema.vaultRequests.id, requestId),
|
|
1050
1148
|
ctxScope(schema.vaultRequests, ctx),
|
|
1149
|
+
eq(schema.vaultRequests.status, "pending"),
|
|
1051
1150
|
),
|
|
1052
|
-
)
|
|
1151
|
+
)
|
|
1152
|
+
.returning();
|
|
1153
|
+
|
|
1154
|
+
if (claimed.length === 0) {
|
|
1155
|
+
return getRequest(requestId, ctx);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const claimedRequest = claimed[0];
|
|
1053
1159
|
|
|
1054
1160
|
await recordVaultAudit({
|
|
1055
1161
|
action: "request.denied",
|
|
1056
|
-
appId:
|
|
1057
|
-
summary: `Denied ${
|
|
1162
|
+
appId: claimedRequest.appId,
|
|
1163
|
+
summary: `Denied ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
|
|
1058
1164
|
metadata: { requestId, reviewer, reason },
|
|
1059
1165
|
});
|
|
1060
1166
|
|