Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
28d3b47
fix(workflow): KEEP-487 stop remounting manual ABI textarea on every …
joelorzet May 14, 2026
2e2c329
fix: KEEP-440 soft-delete workflows to close slug-squat-after-delete
eskp May 14, 2026
0ade3ca
fix: KEEP-440 close remaining soft-delete read-path gaps from review
eskp May 14, 2026
64abef0
fix: add ETH-balance gas preflight to agentic-wallet feedback route
eskp May 15, 2026
eb6a76f
fix(block-dispatcher): tighten failover thresholds to match dashboard…
joelorzet May 15, 2026
7df99dd
Merge pull request #1266 from KeeperHub/fix/block-dispatcher-tighter-…
joelorzet May 15, 2026
4a2a3fc
Merge pull request #1265 from KeeperHub/worktree-fix-feedback-gas-pre…
eskp May 15, 2026
af992b0
Merge pull request #1262 from KeeperHub/feature/keep-440-workflow-slu…
eskp May 15, 2026
87ed35f
fix: KEEP-482 allow NAT64-wrapped public IPv4 in sandbox SSRF guard
suisuss May 15, 2026
2058a35
docs: KEEP-482 document code/run-code egress policy + cover uncompres…
suisuss May 15, 2026
fc0f6ef
style: KEEP-482 use single dash in egress policy prose
suisuss May 15, 2026
b7382e9
Merge pull request #1269 from KeeperHub/feat/KEEP-482-code-run-code-e…
suisuss May 15, 2026
e70d6fb
fix: KEEP-570 reaper now detects monitors stuck across silent reconnects
suisuss May 15, 2026
f525f73
fix(workflow-save): KEEP-571 accept stringified container fields and …
joelorzet May 15, 2026
d08469d
test(workflow-save): KEEP-571 expand coverage with node-count invaria…
joelorzet May 15, 2026
944bf49
chore: KEEP-570 apply biome formatter to satisfy CI lint on reaper fix
joelorzet May 15, 2026
16f6ded
fix(workflow-save): KEEP-571 accept UI-state useManualAbi and cross-a…
joelorzet May 15, 2026
34a44a4
Merge pull request #1273 from KeeperHub/fix/KEEP-571-action-config-ac…
joelorzet May 15, 2026
de67a13
Merge pull request #1272 from KeeperHub/fix/KEEP-570-reaper-detects-s…
joelorzet May 15, 2026
c884512
Merge pull request #1261 from KeeperHub/fix/keep-487-manual-abi-texta…
joelorzet May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/.well-known/x402/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { workflows } from "@/lib/db/schema";
import { workflowNotDeleted } from "@/lib/workflow/soft-delete";

export const dynamic = "force-dynamic";

Expand All @@ -12,7 +13,7 @@ export async function GET(): Promise<Response> {
workflowType: workflows.workflowType,
})
.from(workflows)
.where(eq(workflows.isListed, true));
.where(and(eq(workflows.isListed, true), workflowNotDeleted()));

const resources: string[] = [];
for (const row of rows) {
Expand Down
58 changes: 54 additions & 4 deletions app/api/agentic-wallet/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* 200 { feedbackId, txHash, publicUrl }
* 400 BAD_INPUT
* 401 missing/invalid HMAC
* 402 INSUFFICIENT_GAS -- caller's wallet cannot cover mainnet gas
* 403 NOT_PAYER -- caller's wallet did not pay for the referenced execution
* 404 EXECUTION_NOT_FOUND
* 409 ALREADY_RATED -- caller already rated this execution
Expand Down Expand Up @@ -98,6 +99,25 @@ function badRequest(message: string, code = "BAD_INPUT"): NextResponse {
return NextResponse.json({ error: message, code }, { status: 400 });
}

// Thrown by buildAndSignTx when the caller wallet cannot cover mainnet gas
// for the giveFeedback() tx. Surfaced as a clean 402 INSUFFICIENT_GAS so the
// caller can fund the wallet and retry the now-stranded feedback row
// (KEEP-515), instead of estimateGas/broadcast failing opaquely.
class InsufficientGasError extends Error {
readonly balanceWei: bigint;
readonly requiredWei: bigint | null;
constructor(balanceWei: bigint, requiredWei: bigint | null) {
const need =
requiredWei === null
? "any mainnet gas"
: `the ~${requiredWei} wei needed for mainnet gas`;
super(`Wallet balance ${balanceWei} wei is below ${need}`);
this.name = "InsufficientGasError";
this.balanceWei = balanceWei;
this.requiredWei = requiredWei;
}
}

// Hoisted to module scope per useTopLevelRegex (avoids re-parsing the
// pattern on every parse call).
const INT_LITERAL_RE = /^-?\d+$/;
Expand Down Expand Up @@ -331,14 +351,25 @@ async function buildAndSignTx(args: {
transport: http(rpcUrl),
});

// Live network reads -- nonce + gas estimate + fee estimate. Each one
// hits the upstream RPC; we await sequentially to keep error surfacing
// crisp (and Promise.all would still be sequential because of viem's
// single-connection transport in practice).
// Live network reads -- nonce + balance + gas estimate + fee estimate.
// Each one hits the upstream RPC; we await sequentially to keep error
// surfacing crisp (and Promise.all would still be sequential because of
// viem's single-connection transport in practice).
const nonce = await publicClient.getTransactionCount({
address: args.walletAddress,
blockTag: "pending",
});
// Gas preflight. The caller wallet pays mainnet gas natively, so a wallet
// with no ETH cannot complete this tx. Check the balance up front: a zero
// balance short-circuits before estimateGas, which on a 0-balance account
// can fail opaquely or stall on some RPC providers. A non-zero balance is
// re-checked against the real estimated cost once it is known.
const balanceWei = await publicClient.getBalance({
address: args.walletAddress,
});
if (balanceWei === BigInt(0)) {
throw new InsufficientGasError(balanceWei, null);
}
const gas = await publicClient.estimateGas({
account: args.walletAddress,
to: ERC_8004_REPUTATION_REGISTRY_ADDRESS,
Expand All @@ -349,6 +380,11 @@ async function buildAndSignTx(args: {
// bumps invalidating the broadcast. Cheap insurance on a $3-10 tx.
const gasWithHeadroom = (gas * BigInt(12)) / BigInt(10);
const fees = await publicClient.estimateFeesPerGas();
// Reject before signing if the balance cannot cover the estimated cost.
const requiredWei = gasWithHeadroom * fees.maxFeePerGas;
if (balanceWei < requiredWei) {
throw new InsufficientGasError(balanceWei, requiredWei);
}

const unsignedTx = serializeTransaction({
chainId: args.agentChainId,
Expand Down Expand Up @@ -531,6 +567,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
error: err instanceof Error ? err.message : String(err),
})
.where(eq(feedback.id, feedbackId));
if (err instanceof InsufficientGasError) {
return NextResponse.json(
{
error: err.message,
code: "INSUFFICIENT_GAS",
feedbackId,
balanceWei: err.balanceWei.toString(),
...(err.requiredWei === null
? {}
: { requiredWei: err.requiredWei.toString() }),
},
{ status: 402 }
);
}
if (err instanceof PolicyBlockedError) {
return NextResponse.json(
{ error: err.message, code: "POLICY_BLOCKED", feedbackId },
Expand Down
10 changes: 8 additions & 2 deletions app/api/internal/block-workflows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { type Chain, chains, workflows } from "@/lib/db/schema";
import { authenticateInternalService } from "@/lib/internal-service-auth";
import { ErrorCategory, logSystemError } from "@/lib/logging";
import { workflowNotDeleted } from "@/lib/workflow/soft-delete";
import type { WorkflowNode } from "@/lib/workflow/store";

export async function GET(request: Request): Promise<NextResponse> {
Expand All @@ -21,9 +22,14 @@ export async function GET(request: Request): Promise<NextResponse> {

const blockTriggerFilter = sql`${workflows.nodes} @> '[{"data":{"type":"trigger","config":{"triggerType":"Block"}}}]'::jsonb`;

// KEEP-440: never hand a soft-deleted workflow to the block watcher.
const conditions = filterActive
? and(eq(workflows.enabled, true), blockTriggerFilter)
: blockTriggerFilter;
? and(
eq(workflows.enabled, true),
blockTriggerFilter,
workflowNotDeleted()
)
: and(blockTriggerFilter, workflowNotDeleted());

const matchedWorkflows = await db
.select({
Expand Down
5 changes: 3 additions & 2 deletions app/api/internal/executions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ export async function POST(request: Request): Promise<NextResponse> {

const workflow = await db.query.workflows.findFirst({
where: eq(workflows.id, workflowId),
columns: { organizationId: true },
columns: { organizationId: true, deletedAt: true },
});

if (!workflow) {
// KEEP-440: never create an execution for a soft-deleted workflow.
if (!workflow || workflow.deletedAt) {
return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
}

Expand Down
9 changes: 8 additions & 1 deletion app/api/mcp/workflows/[slug]/call/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type CallRouteWorkflow,
} from "@/lib/payments/x402/types";
import { executeWorkflow } from "@/lib/workflow/executor/executor.workflow";
import { workflowNotDeleted } from "@/lib/workflow/soft-delete";
import type { WorkflowEdge, WorkflowNode } from "@/lib/workflow/store";

const corsHeaders = {
Expand Down Expand Up @@ -185,7 +186,13 @@ async function lookupWorkflow(slug: string): Promise<CallRouteWorkflow | null> {
.select({ ...CALL_ROUTE_COLUMNS, tagName: tags.name })
.from(workflows)
.leftJoin(tags, eq(workflows.tagId, tags.id))
.where(and(eq(workflows.listedSlug, slug), eq(workflows.isListed, true)))
.where(
and(
eq(workflows.listedSlug, slug),
eq(workflows.isListed, true),
workflowNotDeleted()
)
)
.limit(1);
return rows[0] ?? null;
}
Expand Down
4 changes: 3 additions & 1 deletion app/api/mcp/workflows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { workflows } from "@/lib/db/schema";
import { workflowPayments } from "@/lib/db/schema-payments";
import { checkIpRateLimit, getClientIp } from "@/lib/mcp/rate-limit";
import { sanitizeDescription } from "@/lib/sanitize-description";
import { workflowNotDeleted } from "@/lib/workflow/soft-delete";

const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
Expand Down Expand Up @@ -100,7 +101,8 @@ export async function GET(request: Request): Promise<NextResponse> {
const offset = (page - 1) * limit;
const sort = readSort(searchParams.get("sort"));

const baseFilter = eq(workflows.isListed, true);
// KEEP-440: soft-deleted workflows must never surface in the public catalog.
const baseFilter = and(eq(workflows.isListed, true), workflowNotDeleted());
const textFilter = q
? or(
ilike(workflows.name, `%${escapeLikePattern(q)}%`),
Expand Down
5 changes: 3 additions & 2 deletions app/api/openapi/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { workflows } from "@/lib/db/schema";
import { sanitizeDescription } from "@/lib/sanitize-description";
import { workflowNotDeleted } from "@/lib/workflow/soft-delete";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -159,7 +160,7 @@ export async function GET(request: Request): Promise<Response> {
const rows = await db
.select(DISCOVERY_COLUMNS)
.from(workflows)
.where(eq(workflows.isListed, true));
.where(and(eq(workflows.isListed, true), workflowNotDeleted()));

const paths: Record<string, Record<string, unknown>> = {};

Expand Down
6 changes: 4 additions & 2 deletions app/api/workflow/[workflowId]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ export async function POST(
authMethod: "internal",
});

if (!access.hasFullAccess) {
// KEEP-440: a soft-deleted workflow can never be executed.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down Expand Up @@ -165,7 +166,8 @@ export async function POST(
authMethod: authContext.authMethod,
});

if (!access.hasFullAccess) {
// KEEP-440: a soft-deleted workflow can never be executed.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down
8 changes: 8 additions & 0 deletions app/api/workflows/[workflowId]/claim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export async function POST(
);
}

// KEEP-440: a soft-deleted workflow cannot be claimed into an org.
if (workflow.deletedAt) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
);
}

const [updatedWorkflow] = await db
.update(workflows)
.set({
Expand Down
3 changes: 2 additions & 1 deletion app/api/workflows/[workflowId]/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function GET(
authMethod: authContext.authMethod,
});

if (!access.hasFullAccess) {
// KEEP-440: code export is unavailable for a soft-deleted workflow.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down
3 changes: 2 additions & 1 deletion app/api/workflows/[workflowId]/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export async function GET(
authMethod: authContext.authMethod,
});

if (!access.hasFullAccess) {
// KEEP-440: download is unavailable for a soft-deleted workflow.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down
29 changes: 24 additions & 5 deletions app/api/workflows/[workflowId]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { generateId } from "@/lib/utils/id";
import { remapTemplateRefsInString } from "@/lib/utils/template";
import { getWorkflowAccess } from "@/lib/workflow/access";
import { sanitizeWorkflowData } from "@/lib/workflow/editor/sanitize-nodes";
import {
softDeleteValues,
workflowNotDeleted,
} from "@/lib/workflow/soft-delete";
// Node type for type-safe node manipulation
type WorkflowNodeLike = {
id: string;
Expand Down Expand Up @@ -154,6 +158,14 @@ export async function POST(
);
}

// KEEP-440: a soft-deleted workflow cannot be duplicated.
if (access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
);
}

const isAnonymous = !organizationId;

// Generate new IDs for nodes
Expand Down Expand Up @@ -182,13 +194,15 @@ export async function POST(
? await db.query.workflows.findMany({
where: and(
eq(workflows.userId, userId ?? ""),
eq(workflows.isAnonymous, true)
eq(workflows.isAnonymous, true),
workflowNotDeleted()
),
})
: await db.query.workflows.findMany({
where: and(
eq(workflows.organizationId, organizationId ?? ""),
eq(workflows.isAnonymous, false)
eq(workflows.isAnonymous, false),
workflowNotDeleted()
),
});

Expand Down Expand Up @@ -223,14 +237,19 @@ export async function POST(
})
.returning();

// If moving an anonymous workflow to an org, delete the original
// This prevents the old anonymous workflow from being accessible after sign out
// If moving an anonymous workflow to an org, retire the original.
// This prevents the old anonymous workflow from being accessible after
// sign out. KEEP-440: soft-delete so all workflow removal goes through one
// path and the deletedAt filters cover it uniformly.
if (
sourceWorkflow.isAnonymous &&
access.isCreatorWithCurrentAccess &&
!isAnonymous
) {
await db.delete(workflows).where(eq(workflows.id, workflowId));
await db
.update(workflows)
.set(softDeleteValues())
.where(eq(workflows.id, workflowId));
}

return NextResponse.json({
Expand Down
6 changes: 4 additions & 2 deletions app/api/workflows/[workflowId]/executions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export async function GET(
authMethod: authContext.authMethod,
});

if (!access.hasFullAccess) {
// KEEP-440: execution history is hidden once the workflow is soft-deleted.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down Expand Up @@ -104,7 +105,8 @@ export async function DELETE(
authMethod: authContext.authMethod,
});

if (!access.hasFullAccess) {
// KEEP-440: execution history is hidden once the workflow is soft-deleted.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down
3 changes: 2 additions & 1 deletion app/api/workflows/[workflowId]/go-live/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export async function PUT(
authMethod: "session",
});

if (!access.hasFullAccess) {
// KEEP-440: a soft-deleted workflow cannot be taken live.
if (!access.hasFullAccess || access.isDeleted) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 }
Expand Down
Loading
Loading