@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,13 +1,50 @@
|
|
|
1
1
|
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { getDbExec } from "@agent-native/core/db";
|
|
2
3
|
import {
|
|
3
4
|
upsertCustomProvider,
|
|
4
5
|
deleteCustomProvider,
|
|
5
6
|
listCustomProviders,
|
|
6
7
|
getCustomProvider,
|
|
8
|
+
assertCanMutateCustomProviderScope,
|
|
7
9
|
} from "@agent-native/core/provider-api";
|
|
8
10
|
import { getCredentialContext } from "@agent-native/core/server";
|
|
9
11
|
import { z } from "zod";
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the caller's role in a specific org, straight from `org_members`.
|
|
15
|
+
*
|
|
16
|
+
* `getCredentialContext()` (from `@agent-native/core/server`) only exposes
|
|
17
|
+
* `{ userEmail, orgId }` — no role. `getOrgContext()` (from
|
|
18
|
+
* `@agent-native/core/org`) resolves role but requires an `H3Event`, which
|
|
19
|
+
* `defineAction` handlers are not given. This mirrors the established
|
|
20
|
+
* no-event role-lookup idiom already used for org-admin gating inside
|
|
21
|
+
* agent-callable/background code (see `isCurrentUserOrgAdmin` in
|
|
22
|
+
* `packages/core/src/jobs/tools.ts`, `getViewerOrgRole` in
|
|
23
|
+
* `packages/dispatch/src/server/lib/usage-metrics-store.ts`, and the same
|
|
24
|
+
* query in `packages/core/src/mcp/actions/service-token-access.ts`) — same
|
|
25
|
+
* SQL, same fail-closed-to-null-on-error semantics. Returns null (never an
|
|
26
|
+
* org role) on any lookup error or when the caller has no membership row in
|
|
27
|
+
* this org.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveCallerOrgRole(
|
|
30
|
+
orgId: string | null,
|
|
31
|
+
email: string,
|
|
32
|
+
): Promise<string | null> {
|
|
33
|
+
if (!orgId) return null;
|
|
34
|
+
try {
|
|
35
|
+
const client = getDbExec();
|
|
36
|
+
const { rows } = await client.execute({
|
|
37
|
+
sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
|
|
38
|
+
args: [orgId, email.toLowerCase()],
|
|
39
|
+
});
|
|
40
|
+
if (rows.length === 0) return null;
|
|
41
|
+
const role = (rows[0] as { role?: unknown }).role;
|
|
42
|
+
return typeof role === "string" && role ? role : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
11
48
|
const AuthSchema = z.discriminatedUnion("type", [
|
|
12
49
|
z.object({
|
|
13
50
|
type: z.literal("none"),
|
|
@@ -138,6 +175,33 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
138
175
|
const scopeId =
|
|
139
176
|
scope === "org" ? (ctx.orgId ?? ctx.userEmail) : ctx.userEmail;
|
|
140
177
|
|
|
178
|
+
// Only upsert/delete mutate state; list/get remain readable by any org
|
|
179
|
+
// member (scoping org-scoped reads is left as a follow-up — see plan
|
|
180
|
+
// 014). Resolve the caller's role in the *target* org (`scopeId`, which
|
|
181
|
+
// for scope === "org" is exactly `ctx.orgId`) and enforce owner/admin
|
|
182
|
+
// before allowing an org-scoped write. `assertCanMutateCustomProviderScope`
|
|
183
|
+
// is the single source of truth for this check and is also enforced a
|
|
184
|
+
// second time inside `upsertCustomProvider`/`deleteCustomProvider`
|
|
185
|
+
// themselves (defense in depth) — calling it here too gives a clear,
|
|
186
|
+
// early error before any other work happens.
|
|
187
|
+
//
|
|
188
|
+
// When the caller has no active org (`ctx.orgId` is null — a solo user,
|
|
189
|
+
// or an app that hasn't wired `resolveOrgId` at all), `scopeId` above
|
|
190
|
+
// already collapsed to `ctx.userEmail`: there is no shared org resource
|
|
191
|
+
// to protect, and no *other* caller can ever address that same scopeId
|
|
192
|
+
// (every other request's fallback is scoped to *its own* email). Treat
|
|
193
|
+
// that case like sole ownership of a personal bucket — consistent with
|
|
194
|
+
// `org/context.ts`'s auto-created personal org, which also assigns the
|
|
195
|
+
// user role "owner" — rather than hard-rejecting scope: "org" (the
|
|
196
|
+
// action's own default) for every solo user or org-less app.
|
|
197
|
+
let orgRole: string | null = null;
|
|
198
|
+
if ((operation === "upsert" || operation === "delete") && scope === "org") {
|
|
199
|
+
orgRole = ctx.orgId
|
|
200
|
+
? await resolveCallerOrgRole(ctx.orgId, ctx.userEmail)
|
|
201
|
+
: "owner";
|
|
202
|
+
assertCanMutateCustomProviderScope(scope, scopeId, orgRole);
|
|
203
|
+
}
|
|
204
|
+
|
|
141
205
|
if (operation === "list") {
|
|
142
206
|
const providers = await listCustomProviders(scope, scopeId);
|
|
143
207
|
return {
|
|
@@ -165,7 +229,7 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
165
229
|
|
|
166
230
|
if (operation === "delete") {
|
|
167
231
|
if (!id) throw new Error("id is required for delete operation.");
|
|
168
|
-
const deleted = await deleteCustomProvider(scope, scopeId, id);
|
|
232
|
+
const deleted = await deleteCustomProvider(scope, scopeId, id, orgRole);
|
|
169
233
|
return { deleted, id };
|
|
170
234
|
}
|
|
171
235
|
|
|
@@ -186,6 +250,7 @@ After registration the provider appears in provider-api-catalog and can be used
|
|
|
186
250
|
allowedHostSuffixes,
|
|
187
251
|
defaultHeaders,
|
|
188
252
|
notes,
|
|
253
|
+
orgRole,
|
|
189
254
|
});
|
|
190
255
|
|
|
191
256
|
return {
|
|
@@ -59,7 +59,16 @@ vi.mock("@agent-native/core/integrations", () => ({
|
|
|
59
59
|
documentation: { href: "/docs/messaging#slack" },
|
|
60
60
|
setup: { steps: ["Create a Slack app."] },
|
|
61
61
|
credentialRequirements: [
|
|
62
|
-
{
|
|
62
|
+
{
|
|
63
|
+
key: "SLACK_BOT_TOKEN",
|
|
64
|
+
label: "Slack Bot Token (legacy)",
|
|
65
|
+
required: false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "SLACK_CLIENT_ID",
|
|
69
|
+
label: "Slack OAuth Client ID",
|
|
70
|
+
required: true,
|
|
71
|
+
},
|
|
63
72
|
],
|
|
64
73
|
},
|
|
65
74
|
{
|
|
@@ -142,6 +151,7 @@ describe("MessagingSetupPanel", () => {
|
|
|
142
151
|
expect(container.textContent).toContain(
|
|
143
152
|
"Save the required Slack app credentials below",
|
|
144
153
|
);
|
|
154
|
+
expect(container.textContent).toContain("Slack Bot Token (legacy)");
|
|
145
155
|
});
|
|
146
156
|
|
|
147
157
|
it("shows connected and alternative credential states", async () => {
|
|
@@ -155,6 +165,12 @@ describe("MessagingSetupPanel", () => {
|
|
|
155
165
|
required: true,
|
|
156
166
|
configured: true,
|
|
157
167
|
},
|
|
168
|
+
{
|
|
169
|
+
key: "SLACK_CLIENT_ID",
|
|
170
|
+
label: "Slack OAuth Client ID",
|
|
171
|
+
required: true,
|
|
172
|
+
configured: true,
|
|
173
|
+
},
|
|
158
174
|
{
|
|
159
175
|
key: "RESEND_API_KEY",
|
|
160
176
|
label: "Resend API Key",
|
|
@@ -455,6 +455,12 @@ export function MessagingSetupPanel() {
|
|
|
455
455
|
));
|
|
456
456
|
const enabled = !!status?.enabled;
|
|
457
457
|
const envKeys = platform.credentialRequirements;
|
|
458
|
+
const primaryEnvKeys = envKeys.filter(
|
|
459
|
+
(envKey) => envKey.key !== "SLACK_BOT_TOKEN",
|
|
460
|
+
);
|
|
461
|
+
const legacyEnvKeys = envKeys.filter(
|
|
462
|
+
(envKey) => envKey.key === "SLACK_BOT_TOKEN",
|
|
463
|
+
);
|
|
458
464
|
const missingRequiredCredentials = hasMissingRequiredCredentials(
|
|
459
465
|
envKeys,
|
|
460
466
|
envStatusByKey,
|
|
@@ -789,7 +795,7 @@ export function MessagingSetupPanel() {
|
|
|
789
795
|
) : null}
|
|
790
796
|
</div>
|
|
791
797
|
<div className="space-y-3">
|
|
792
|
-
{
|
|
798
|
+
{primaryEnvKeys.map((envKey) => {
|
|
793
799
|
const envStatus = envStatusByKey.get(envKey.key);
|
|
794
800
|
const isConfigured = !!envStatus?.configured;
|
|
795
801
|
const helpText = envKey.helpText ?? envStatus?.helpText;
|
|
@@ -847,6 +853,80 @@ export function MessagingSetupPanel() {
|
|
|
847
853
|
);
|
|
848
854
|
})}
|
|
849
855
|
</div>
|
|
856
|
+
{legacyEnvKeys.length ? (
|
|
857
|
+
<Collapsible>
|
|
858
|
+
<CollapsibleTrigger className="group flex w-full cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
859
|
+
<IconChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
|
|
860
|
+
<span>{legacyEnvKeys[0]?.label}</span>
|
|
861
|
+
</CollapsibleTrigger>
|
|
862
|
+
<CollapsibleContent>
|
|
863
|
+
<div className="mt-2 space-y-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4">
|
|
864
|
+
<p className="text-xs text-muted-foreground">
|
|
865
|
+
{legacyEnvKeys[0]?.helpText}
|
|
866
|
+
</p>
|
|
867
|
+
{legacyEnvKeys.map((envKey) => {
|
|
868
|
+
const envStatus = envStatusByKey.get(envKey.key);
|
|
869
|
+
const isConfigured = !!envStatus?.configured;
|
|
870
|
+
const helpText =
|
|
871
|
+
envKey.helpText ?? envStatus?.helpText;
|
|
872
|
+
const label =
|
|
873
|
+
envKey.label || envStatus?.label || envKey.key;
|
|
874
|
+
return (
|
|
875
|
+
<div key={envKey.key} className="space-y-1.5">
|
|
876
|
+
<div className="flex items-center justify-between gap-3">
|
|
877
|
+
<div className="flex items-center gap-1.5">
|
|
878
|
+
<label className="text-xs font-medium text-foreground">
|
|
879
|
+
{label}
|
|
880
|
+
</label>
|
|
881
|
+
{helpText ? (
|
|
882
|
+
<HelpTooltip content={helpText} />
|
|
883
|
+
) : null}
|
|
884
|
+
</div>
|
|
885
|
+
<StatusPill
|
|
886
|
+
tone={isConfigured ? "success" : "neutral"}
|
|
887
|
+
label={isConfigured ? "Saved" : "Not set"}
|
|
888
|
+
/>
|
|
889
|
+
</div>
|
|
890
|
+
{!isConfigured ? (
|
|
891
|
+
<Input
|
|
892
|
+
type="password"
|
|
893
|
+
value={envValues[envKey.key] || ""}
|
|
894
|
+
onChange={(event) =>
|
|
895
|
+
setEnvValues((current) => ({
|
|
896
|
+
...current,
|
|
897
|
+
[envKey.key]: event.target.value,
|
|
898
|
+
}))
|
|
899
|
+
}
|
|
900
|
+
placeholder={`Enter ${label}`}
|
|
901
|
+
autoComplete="off"
|
|
902
|
+
/>
|
|
903
|
+
) : null}
|
|
904
|
+
</div>
|
|
905
|
+
);
|
|
906
|
+
})}
|
|
907
|
+
{legacyEnvKeys.some(
|
|
908
|
+
(envKey) =>
|
|
909
|
+
!envStatusByKey.get(envKey.key)?.configured,
|
|
910
|
+
) ? (
|
|
911
|
+
<Button
|
|
912
|
+
variant="outline"
|
|
913
|
+
onClick={() =>
|
|
914
|
+
saveEnvKeys(
|
|
915
|
+
platform,
|
|
916
|
+
legacyEnvKeys.map((envKey) => envKey.key),
|
|
917
|
+
)
|
|
918
|
+
}
|
|
919
|
+
disabled={savingKeysFor === platform.id}
|
|
920
|
+
>
|
|
921
|
+
{savingKeysFor === platform.id
|
|
922
|
+
? "Saving..."
|
|
923
|
+
: "Save credentials"}
|
|
924
|
+
</Button>
|
|
925
|
+
) : null}
|
|
926
|
+
</div>
|
|
927
|
+
</CollapsibleContent>
|
|
928
|
+
</Collapsible>
|
|
929
|
+
) : null}
|
|
850
930
|
{missingRequiredCredentials ? (
|
|
851
931
|
<Button
|
|
852
932
|
variant="outline"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
// Each test runs real migrations against a fresh SQLite file; under full
|
|
8
|
+
// workspace concurrency that setup can exceed the 5s default timeout.
|
|
9
|
+
vi.setConfig({ testTimeout: 30_000 });
|
|
10
|
+
|
|
11
|
+
const ownerEmail = "owner+approval-fencing@example.test";
|
|
12
|
+
const orgId = "org_approval_fencing";
|
|
13
|
+
|
|
14
|
+
const originalEnv = {
|
|
15
|
+
APP_NAME: process.env.APP_NAME,
|
|
16
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
17
|
+
DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN,
|
|
18
|
+
DISPATCH_DATABASE_URL: process.env.DISPATCH_DATABASE_URL,
|
|
19
|
+
DISPATCH_DATABASE_AUTH_TOKEN: process.env.DISPATCH_DATABASE_AUTH_TOKEN,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let tempDir: string | null = null;
|
|
23
|
+
|
|
24
|
+
function restoreEnv() {
|
|
25
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
26
|
+
if (value === undefined) delete process.env[key];
|
|
27
|
+
else process.env[key] = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tempDir = fs.mkdtempSync(
|
|
33
|
+
path.join(os.tmpdir(), "dispatch-approval-fencing-"),
|
|
34
|
+
);
|
|
35
|
+
process.env.DATABASE_URL = `file:${path.join(tempDir, "app.db")}`;
|
|
36
|
+
delete process.env.APP_NAME;
|
|
37
|
+
delete process.env.DATABASE_AUTH_TOKEN;
|
|
38
|
+
delete process.env.DISPATCH_DATABASE_URL;
|
|
39
|
+
delete process.env.DISPATCH_DATABASE_AUTH_TOKEN;
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
|
|
42
|
+
const [{ runMigrations }, { dispatchMigrations }] = await Promise.all([
|
|
43
|
+
import("@agent-native/core/db"),
|
|
44
|
+
import("../../db/migrations.js"),
|
|
45
|
+
]);
|
|
46
|
+
await runMigrations(dispatchMigrations, {
|
|
47
|
+
table: "dispatch_migrations",
|
|
48
|
+
})({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
try {
|
|
53
|
+
const { closeDbExec } = await import("@agent-native/core/db");
|
|
54
|
+
await closeDbExec();
|
|
55
|
+
} catch {}
|
|
56
|
+
restoreEnv();
|
|
57
|
+
if (tempDir) {
|
|
58
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
59
|
+
tempDir = null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("dispatch approval request status fencing", () => {
|
|
64
|
+
it("applies the change once when approveRequest is called twice on the same request", async () => {
|
|
65
|
+
const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
|
|
66
|
+
await Promise.all([
|
|
67
|
+
import("@agent-native/core/server"),
|
|
68
|
+
import("@agent-native/core/db"),
|
|
69
|
+
import("./dispatch-store.js"),
|
|
70
|
+
]);
|
|
71
|
+
const exec = getDbExec();
|
|
72
|
+
|
|
73
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
74
|
+
const created = await dispatchStore.createApprovalRequest({
|
|
75
|
+
changeType: "approval-policy.update",
|
|
76
|
+
targetType: "dispatch-settings",
|
|
77
|
+
targetId: "dispatch-approval-policy",
|
|
78
|
+
summary: "Enable approval policy",
|
|
79
|
+
payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
|
|
80
|
+
});
|
|
81
|
+
const requestId = (created as any).id;
|
|
82
|
+
|
|
83
|
+
const first = await dispatchStore.approveRequest(requestId);
|
|
84
|
+
expect(first.status).toBe("approved");
|
|
85
|
+
expect(first.reviewedBy).toBe(ownerEmail);
|
|
86
|
+
expect(first.reviewedAt).toBeTruthy();
|
|
87
|
+
|
|
88
|
+
expect(await dispatchStore.getApprovalPolicy()).toEqual({
|
|
89
|
+
enabled: true,
|
|
90
|
+
approverEmails: ["reviewer@example.test"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const approvedAuditCount = async () => {
|
|
94
|
+
const rows = await exec.execute({
|
|
95
|
+
sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.approved' AND target_id = ?",
|
|
96
|
+
args: [requestId],
|
|
97
|
+
});
|
|
98
|
+
return Number((rows.rows[0] as any).count);
|
|
99
|
+
};
|
|
100
|
+
expect(await approvedAuditCount()).toBe(1);
|
|
101
|
+
|
|
102
|
+
// A concurrent second approve landing after the first already won the
|
|
103
|
+
// race must find the row no longer 'pending': the fenced UPDATE
|
|
104
|
+
// affects zero rows, so it must not re-apply the change.
|
|
105
|
+
const second = await dispatchStore.approveRequest(requestId);
|
|
106
|
+
expect(second.status).toBe("approved");
|
|
107
|
+
expect(second.reviewedAt).toBe(first.reviewedAt);
|
|
108
|
+
expect(await approvedAuditCount()).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reclaims a stale applying lease after a worker crash", async () => {
|
|
113
|
+
const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
|
|
114
|
+
await Promise.all([
|
|
115
|
+
import("@agent-native/core/server"),
|
|
116
|
+
import("@agent-native/core/db"),
|
|
117
|
+
import("./dispatch-store.js"),
|
|
118
|
+
]);
|
|
119
|
+
const exec = getDbExec();
|
|
120
|
+
|
|
121
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
122
|
+
const created = await dispatchStore.createApprovalRequest({
|
|
123
|
+
changeType: "approval-policy.update",
|
|
124
|
+
targetType: "dispatch-settings",
|
|
125
|
+
targetId: "dispatch-approval-policy",
|
|
126
|
+
summary: "Enable approval policy after recovery",
|
|
127
|
+
payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
|
|
128
|
+
});
|
|
129
|
+
const requestId = (created as any).id;
|
|
130
|
+
|
|
131
|
+
await exec.execute({
|
|
132
|
+
sql: "UPDATE dispatch_approval_requests SET status = ?, updated_at = ? WHERE id = ?",
|
|
133
|
+
args: ["applying", Date.now() - 6 * 60 * 1000, requestId],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const recovered = await dispatchStore.approveRequest(requestId);
|
|
137
|
+
expect(recovered.status).toBe("approved");
|
|
138
|
+
expect(recovered.reviewedBy).toBe(ownerEmail);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("keeps a failed apply leased instead of returning it to pending", async () => {
|
|
143
|
+
const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
|
|
144
|
+
await Promise.all([
|
|
145
|
+
import("@agent-native/core/server"),
|
|
146
|
+
import("@agent-native/core/db"),
|
|
147
|
+
import("./dispatch-store.js"),
|
|
148
|
+
]);
|
|
149
|
+
const exec = getDbExec();
|
|
150
|
+
|
|
151
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
152
|
+
const created = await dispatchStore.createApprovalRequest({
|
|
153
|
+
changeType: "unsupported.partial-effect",
|
|
154
|
+
targetType: "test",
|
|
155
|
+
summary: "Do not immediately retry ambiguous work",
|
|
156
|
+
payload: {},
|
|
157
|
+
});
|
|
158
|
+
const requestId = (created as any).id;
|
|
159
|
+
|
|
160
|
+
await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
|
|
161
|
+
"Unsupported approval request type",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const rows = await exec.execute({
|
|
165
|
+
sql: "SELECT status FROM dispatch_approval_requests WHERE id = ?",
|
|
166
|
+
args: [requestId],
|
|
167
|
+
});
|
|
168
|
+
expect(rows.rows[0]).toMatchObject({ status: "applying" });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rejects the change once when rejectRequest is called twice on the same request", async () => {
|
|
173
|
+
const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
|
|
174
|
+
await Promise.all([
|
|
175
|
+
import("@agent-native/core/server"),
|
|
176
|
+
import("@agent-native/core/db"),
|
|
177
|
+
import("./dispatch-store.js"),
|
|
178
|
+
]);
|
|
179
|
+
const exec = getDbExec();
|
|
180
|
+
|
|
181
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
182
|
+
const created = await dispatchStore.createApprovalRequest({
|
|
183
|
+
changeType: "approval-policy.update",
|
|
184
|
+
targetType: "dispatch-settings",
|
|
185
|
+
targetId: "dispatch-approval-policy",
|
|
186
|
+
summary: "Disable approval policy",
|
|
187
|
+
payload: { enabled: false, approverEmails: [] },
|
|
188
|
+
});
|
|
189
|
+
const requestId = (created as any).id;
|
|
190
|
+
|
|
191
|
+
const first = await dispatchStore.rejectRequest(requestId, "not needed");
|
|
192
|
+
expect(first?.status).toBe("rejected");
|
|
193
|
+
expect(first?.reviewedAt).toBeTruthy();
|
|
194
|
+
|
|
195
|
+
const rejectedAuditCount = async () => {
|
|
196
|
+
const rows = await exec.execute({
|
|
197
|
+
sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.rejected' AND target_id = ?",
|
|
198
|
+
args: [requestId],
|
|
199
|
+
});
|
|
200
|
+
return Number((rows.rows[0] as any).count);
|
|
201
|
+
};
|
|
202
|
+
expect(await rejectedAuditCount()).toBe(1);
|
|
203
|
+
|
|
204
|
+
const second = await dispatchStore.rejectRequest(
|
|
205
|
+
requestId,
|
|
206
|
+
"still not needed",
|
|
207
|
+
);
|
|
208
|
+
expect(second?.status).toBe("rejected");
|
|
209
|
+
expect(second?.reviewedAt).toBe(first?.reviewedAt);
|
|
210
|
+
expect(await rejectedAuditCount()).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("vault request status fencing", () => {
|
|
216
|
+
it("does not duplicate a grant when createGrant is retried", async () => {
|
|
217
|
+
const [{ runWithRequestContext }, vaultStore] = await Promise.all([
|
|
218
|
+
import("@agent-native/core/server"),
|
|
219
|
+
import("./vault-store.js"),
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
223
|
+
const secret = await vaultStore.createSecret({
|
|
224
|
+
credentialKey: "RETRY_GRANT_API_KEY",
|
|
225
|
+
value: "secret-value",
|
|
226
|
+
name: "Retry Grant Secret",
|
|
227
|
+
});
|
|
228
|
+
expect(secret).toBeTruthy();
|
|
229
|
+
|
|
230
|
+
const first = await vaultStore.createGrant(secret!.id, "test-app");
|
|
231
|
+
const retry = await vaultStore.createGrant(secret!.id, "test-app");
|
|
232
|
+
|
|
233
|
+
expect(retry?.id).toBe(first?.id);
|
|
234
|
+
expect(await vaultStore.listGrants({ appId: "test-app" })).toHaveLength(
|
|
235
|
+
1,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("creates a single grant when approveRequest is called twice on the same request", async () => {
|
|
241
|
+
const [{ runWithRequestContext }, vaultStore] = await Promise.all([
|
|
242
|
+
import("@agent-native/core/server"),
|
|
243
|
+
import("./vault-store.js"),
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
247
|
+
const created = await vaultStore.createRequest({
|
|
248
|
+
credentialKey: "TEST_API_KEY",
|
|
249
|
+
appId: "test-app",
|
|
250
|
+
reason: "needed for tests",
|
|
251
|
+
});
|
|
252
|
+
const requestId = (created as any).id;
|
|
253
|
+
|
|
254
|
+
const first = await vaultStore.approveRequest(
|
|
255
|
+
requestId,
|
|
256
|
+
"secret-value-1",
|
|
257
|
+
"Test Secret",
|
|
258
|
+
);
|
|
259
|
+
expect(first?.status).toBe("approved");
|
|
260
|
+
|
|
261
|
+
const grantsAfterFirst = await vaultStore.listGrants({
|
|
262
|
+
appId: "test-app",
|
|
263
|
+
});
|
|
264
|
+
expect(grantsAfterFirst).toHaveLength(1);
|
|
265
|
+
|
|
266
|
+
// Loser of the race: the row is already 'approved', so this must not
|
|
267
|
+
// create a second grant for the same request.
|
|
268
|
+
const second = await vaultStore.approveRequest(
|
|
269
|
+
requestId,
|
|
270
|
+
"secret-value-2",
|
|
271
|
+
"Test Secret",
|
|
272
|
+
);
|
|
273
|
+
expect(second?.status).toBe("approved");
|
|
274
|
+
|
|
275
|
+
const grantsAfterSecond = await vaultStore.listGrants({
|
|
276
|
+
appId: "test-app",
|
|
277
|
+
});
|
|
278
|
+
expect(grantsAfterSecond).toHaveLength(1);
|
|
279
|
+
expect(grantsAfterSecond[0].id).toBe(grantsAfterFirst[0].id);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("denies once when denyRequest is called twice on the same request", async () => {
|
|
284
|
+
const [{ runWithRequestContext }, vaultStore] = await Promise.all([
|
|
285
|
+
import("@agent-native/core/server"),
|
|
286
|
+
import("./vault-store.js"),
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
|
|
290
|
+
const created = await vaultStore.createRequest({
|
|
291
|
+
credentialKey: "OTHER_API_KEY",
|
|
292
|
+
appId: "test-app",
|
|
293
|
+
reason: "needed for tests",
|
|
294
|
+
});
|
|
295
|
+
const requestId = (created as any).id;
|
|
296
|
+
|
|
297
|
+
const first = await vaultStore.denyRequest(requestId, "not approved");
|
|
298
|
+
expect(first?.status).toBe("denied");
|
|
299
|
+
expect(first?.reviewedAt).toBeTruthy();
|
|
300
|
+
|
|
301
|
+
const second = await vaultStore.denyRequest(
|
|
302
|
+
requestId,
|
|
303
|
+
"still not approved",
|
|
304
|
+
);
|
|
305
|
+
expect(second?.status).toBe("denied");
|
|
306
|
+
expect(second?.reviewedAt).toBe(first?.reviewedAt);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|