@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.
@@ -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
- { key: "SLACK_BOT_TOKEN", label: "Slack Bot Token", required: true },
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
- {envKeys.map((envKey) => {
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
+ });