@agent-native/dispatch 0.14.2 → 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 {
@@ -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
+ });
@@ -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 getApprovalPolicy();
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
- if (request.status !== "pending") {
614
- throw new Error("Only pending approvals can be approved");
615
- }
618
+
616
619
  const timestamp = now();
617
- await db
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: timestamp,
679
+ updatedAt: now(),
624
680
  })
625
- .where(eq(schema.dispatchApprovalRequests.id, requestId));
626
- const updated = await getApprovalRequest(requestId, ctx);
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 request = await getApprovalRequest(requestId);
701
+ const ctx = requireDispatchCtx();
702
+ const request = await getApprovalRequest(requestId, ctx);
642
703
  if (!request) throw new Error("Approval request not found");
643
- if (request.status !== "pending") {
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(eq(schema.dispatchApprovalRequests.id, requestId));
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: request.targetType,
729
+ targetType: updated.targetType,
659
730
  targetId: requestId,
660
- summary: `Rejected ${request.summary}`,
661
- metadata: { request, reason: reason || null },
731
+ summary: `Rejected ${updated.summary}`,
732
+ metadata: { request: updated, reason: reason || null },
662
733
  });
663
- return getApprovalRequest(requestId);
734
+ return updated;
664
735
  }
665
736
 
666
737
  export async function createLinkToken(platform: string) {