From d1696df6aaa93e51dac2ea1890fb93c6d0108f3f Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Mon, 12 Jan 2026 18:39:50 +0700
Subject: [PATCH 1/5] Preserve wallet address on vault credential rotation
Refactored vault credential rotation logic to avoid creating a new server wallet and to preserve the existing project wallet address during rotation. This prevents loss of wallet association and ensures credentials are saved immediately after rotation, reducing risk of unrecoverable state. Also updated related helper logic to support this flow.
---
apps/dashboard/src/@/hooks/useApi.ts | 24 +-
.../transactions/lib/vault.client.ts | 118 ++++-----
.../wallets/server-wallets/wallets/page.tsx | 11 +-
.../wallets/vault-recovery-card.client.tsx | 225 ++++++++++++++++++
4 files changed, 301 insertions(+), 77 deletions(-)
create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx
diff --git a/apps/dashboard/src/@/hooks/useApi.ts b/apps/dashboard/src/@/hooks/useApi.ts
index 18c6e4f1940..faf7d93c048 100644
--- a/apps/dashboard/src/@/hooks/useApi.ts
+++ b/apps/dashboard/src/@/hooks/useApi.ts
@@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) {
throw new Error(res.error);
}
- try {
- // if the project has an encrypted vault admin key, rotate it as well
- const service = params.project.services.find(
- (service) => service.name === "engineCloud",
- );
- if (service?.encryptedAdminKey) {
- await rotateVaultAccountAndAccessToken({
- project: params.project,
- projectSecretKey: res.data.data.secret,
- projectSecretHash: res.data.data.secretHash,
- });
- }
- } catch (error) {
- console.error("Failed to rotate vault admin key", error);
+ // if the project has an encrypted vault admin key, rotate it as well
+ const service = params.project.services.find(
+ (service) => service.name === "engineCloud",
+ );
+ if (service?.encryptedAdminKey) {
+ await rotateVaultAccountAndAccessToken({
+ project: params.project,
+ projectSecretKey: res.data.data.secret,
+ projectSecretHash: res.data.data.secretHash,
+ });
}
return res.data;
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
index ac5ac44220e..52757021507 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
@@ -69,6 +69,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
vaultClient,
adminKey,
rotationCode,
+ // Skip wallet creation on rotation - preserve the existing project wallet
+ skipWalletCreation: true,
+ existingProjectWalletAddress: service?.projectWalletAddress ?? undefined,
});
return {
@@ -222,9 +225,18 @@ async function createAndEncryptVaultAccessTokens(props: {
projectSecretHash?: string;
adminKey: string;
rotationCode: string;
+ skipWalletCreation?: boolean;
+ existingProjectWalletAddress?: string;
}) {
- const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
- props;
+ const {
+ project,
+ projectSecretKey,
+ vaultClient,
+ adminKey,
+ rotationCode,
+ skipWalletCreation,
+ existingProjectWalletAddress,
+ } = props;
const [managementTokenResult, walletTokenResult] = await Promise.all([
createManagementAccessToken({ project, adminKey, vaultClient }),
@@ -246,12 +258,12 @@ async function createAndEncryptVaultAccessTokens(props: {
const managementToken = managementTokenResult.data;
const walletToken = walletTokenResult.data;
- // create a default project server wallet
- const defaultProjectServerWallet = await createProjectServerWallet({
- project,
- managementAccessToken: managementToken.accessToken,
- label: getProjectWalletLabel(project.name),
- });
+ // CRITICAL: Save credentials IMMEDIATELY after creating tokens.
+ // This prevents a broken state if wallet creation or other operations fail.
+ // The rotationCode is consumed when rotating, so if we don't save the new one,
+ // the project becomes unrecoverable.
+ let encryptedAdminKey: string | null = null;
+ let encryptedWalletAccessToken: string | null = null;
if (projectSecretKey) {
// verify that the project secret key is valid
@@ -266,61 +278,53 @@ async function createAndEncryptVaultAccessTokens(props: {
}
// encrypt admin key and wallet token with project secret key
- const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
+ [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
encrypt(adminKey, projectSecretKey),
encrypt(walletToken.accessToken, projectSecretKey),
]);
+ }
- await updateProjectClient(
- {
- projectId: props.project.id,
- teamId: props.project.teamId,
- },
- {
- services: [
- ...props.project.services.filter(
- (service) => service.name !== "engineCloud",
- ),
- {
- name: "engineCloud",
- actions: [],
- managementAccessToken: managementToken.accessToken,
- maskedAdminKey: maskSecret(adminKey),
- encryptedAdminKey,
- encryptedWalletAccessToken,
- rotationCode: rotationCode,
- projectWalletAddress: defaultProjectServerWallet.address,
- },
- ],
- },
- );
- } else {
- // no secret key, only store the management token, remove any encrypted keys
- await updateProjectClient(
- {
- projectId: props.project.id,
- teamId: props.project.teamId,
- },
- {
- services: [
- ...props.project.services.filter(
- (service) => service.name !== "engineCloud",
- ),
- {
- name: "engineCloud",
- actions: [],
- managementAccessToken: managementToken.accessToken,
- maskedAdminKey: maskSecret(adminKey),
- encryptedAdminKey: null,
- encryptedWalletAccessToken: null,
- rotationCode: rotationCode,
- projectWalletAddress: defaultProjectServerWallet.address,
- },
- ],
- },
- );
+ // For rotation, preserve existing wallet address. For new creation, create a default wallet.
+ let projectWalletAddress: string | null | undefined =
+ existingProjectWalletAddress ??
+ project.services.find((s) => s.name === "engineCloud")
+ ?.projectWalletAddress;
+
+ // Only create a new wallet if we don't have one (initial setup, not rotation)
+ if (!skipWalletCreation && !projectWalletAddress) {
+ const defaultProjectServerWallet = await createProjectServerWallet({
+ project,
+ managementAccessToken: managementToken.accessToken,
+ label: getProjectWalletLabel(project.name),
+ });
+ projectWalletAddress = defaultProjectServerWallet.address;
}
+ // Save credentials
+ await updateProjectClient(
+ {
+ projectId: props.project.id,
+ teamId: props.project.teamId,
+ },
+ {
+ services: [
+ ...props.project.services.filter(
+ (service) => service.name !== "engineCloud",
+ ),
+ {
+ name: "engineCloud",
+ actions: [],
+ managementAccessToken: managementToken.accessToken,
+ maskedAdminKey: maskSecret(adminKey),
+ encryptedAdminKey,
+ encryptedWalletAccessToken,
+ rotationCode: rotationCode,
+ projectWalletAddress: projectWalletAddress ?? null,
+ },
+ ],
+ },
+ );
+
return {
managementToken,
walletToken,
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx
index e309ae2d755..6943181fdff 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/page.tsx
@@ -9,6 +9,7 @@ import { ServerWalletsTable } from "../../../transactions/components/server-wall
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
+import { VaultRecoveryCard } from "./vault-recovery-card.client";
export const dynamic = "force-dynamic";
@@ -107,12 +108,10 @@ export default async function Page(props: {
return (
{eoas.error ? (
-
-
- EVM Wallet Error
-
-
{eoas.error.message}
-
+
) : (
s.name === "engineCloud",
+ );
+ const wasManagedVault = !!engineCloudService?.encryptedAdminKey;
+
+ const isInsufficientScopeError =
+ errorMessage.includes("AUTH_INSUFFICIENT_SCOPE") ||
+ errorMessage.toLowerCase().includes("insufficient scope");
+
+ const regenerateMutation = useMutation({
+ mutationFn: async () => {
+ await initVaultClient();
+
+ const result = await createVaultAccountAndAccessToken({
+ project,
+ // Only pass secret key if it was a managed vault and user provided one
+ projectSecretKey: wasManagedVault ? secretKeyInput : undefined,
+ });
+
+ return result;
+ },
+ onSuccess: () => {
+ // Refresh the page to show the new wallet state
+ window.location.reload();
+ },
+ });
+
+ // For managed vaults, require secret key input
+ const canProceed = wasManagedVault
+ ? confirmed && secretKeyInput.trim().length > 0
+ : confirmed;
+
+ if (!isInsufficientScopeError) {
+ // Show standard error for non-scope errors
+ return (
+
+
EVM Wallet Error
+
{errorMessage}
+
+ );
+ }
+
+ return (
+
+
+ Server Wallet Access Lost
+
+
+ Your server wallet credentials have become invalid. This can happen if
+ the secret key was rotated but the new credentials weren't saved
+ properly.
+
+
+
+
+ ⚠️ Important: Existing wallets cannot be recovered
+
+
+ If you have existing wallets that are important to your project,
+ please{" "}
+
+ contact support
+ {" "}
+ before proceeding.
+
+
+
+
+
+
+
+
+
+
+ Create New Server Wallet Configuration?
+
+
+
+
+ This will create a completely new server wallet
+ configuration for your project.
+
+
+
+ Warning: This action cannot be undone
+
+
+
+ Any existing server wallets will be permanently
+ inaccessible
+
+
+ You will need to create new wallets after this process
+
+
+
+
+ {wasManagedVault && (
+
+
+ setSecretKeyInput(e.target.value)}
+ />
+
+ Your secret key is required to create a managed vault.
+
+ Failed to create new configuration:{" "}
+ {regenerateMutation.error.message}
+
+ )}
+
+
+ Error details: {errorMessage}
+
+
+
+ );
+}
From 9cd153febff56e936120bcd35ae93931e2dccd1f Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Mon, 12 Jan 2026 18:44:30 +0700
Subject: [PATCH 2/5] Add admin key download flow for ejected vault recovery
Implements a secure admin key download and confirmation step when regenerating server wallet configuration for ejected vaults. The UI now prompts users to download and confirm saving the admin key before proceeding, ensuring keys are not lost. Managed vaults retain the previous secret key input flow.
---
.../wallets/vault-recovery-card.client.tsx | 305 +++++++++++++-----
1 file changed, 216 insertions(+), 89 deletions(-)
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx
index a1ec432e14f..4ecf93e63af 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx
@@ -1,8 +1,14 @@
"use client";
import { useMutation } from "@tanstack/react-query";
-import { AlertTriangleIcon, RotateCcwIcon } from "lucide-react";
+import {
+ AlertTriangleIcon,
+ CheckIcon,
+ DownloadIcon,
+ RotateCcwIcon,
+} from "lucide-react";
import { useState } from "react";
+import { toast } from "sonner";
import type { Project } from "@/api/project/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
@@ -17,12 +23,14 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
+import { CopyTextButton } from "@/components/ui/CopyTextButton";
+import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/Spinner";
import {
createVaultAccountAndAccessToken,
initVaultClient,
+ maskSecret,
} from "../../../transactions/lib/vault.client";
interface VaultRecoveryCardProps {
@@ -37,6 +45,9 @@ export function VaultRecoveryCard({
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [secretKeyInput, setSecretKeyInput] = useState("");
+ // For ejected vault key download flow
+ const [keysConfirmed, setKeysConfirmed] = useState(false);
+ const [keysDownloaded, setKeysDownloaded] = useState(false);
// Check if this was a managed vault (had encryptedAdminKey)
const engineCloudService = project.services.find(
@@ -61,11 +72,44 @@ export function VaultRecoveryCard({
return result;
},
onSuccess: () => {
- // Refresh the page to show the new wallet state
- window.location.reload();
+ // For managed vaults, reload immediately (keys are encrypted with secret key)
+ // For ejected vaults, show the key download dialog first
+ if (wasManagedVault) {
+ window.location.reload();
+ }
+ // For ejected vaults, we stay in the dialog to show the admin key
},
});
+ const handleDownloadKeys = () => {
+ if (!regenerateMutation.data) {
+ return;
+ }
+
+ const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${regenerateMutation.data.adminKey}\n\nVault Access Token:\n${regenerateMutation.data.walletToken.accessToken}\n`;
+ const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ const filename = `${project.name}-vault-keys.txt`;
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ toast.success(`Keys downloaded as ${filename}`);
+ setKeysDownloaded(true);
+ };
+
+ const handleCloseAfterKeysSaved = () => {
+ if (!keysConfirmed) {
+ return;
+ }
+ window.location.reload();
+ };
+
// For managed vaults, require secret key input
const canProceed = wasManagedVault
? confirmed && secretKeyInput.trim().length > 0
@@ -119,93 +163,176 @@ export function VaultRecoveryCard({
-
-
- Create New Server Wallet Configuration?
-
-
-
-
- This will create a completely new server wallet
- configuration for your project.
-
-
-
- Warning: This action cannot be undone
-
-
-
- Any existing server wallets will be permanently
- inaccessible
-
-
- You will need to create new wallets after this process
-
-
-
-
- {wasManagedVault && (
-
-
- setSecretKeyInput(e.target.value)}
- />
-
- Your secret key is required to create a managed vault.
+ {/* Show key download UI for ejected vaults after success */}
+ {!wasManagedVault && regenerateMutation.data ? (
+ <>
+
+ Save your Vault Admin Key
+
+
+
+ You'll need this key to create server wallets and
+ access tokens.
+
+
+
+
+ Download this key to your local machine or a password
+ manager.
+
+
+
+
+ Secure your admin key
+
+ This key will not be displayed again. Store it
+ securely as it provides access to your server wallets.
+
+
+
+ {keysDownloaded && (
+
+
+
+ )}
+
+
+
+ setKeysConfirmed(!!v)}
+ />
+ I confirm that I've securely stored my admin
+ key
+
+
+
+
+
+
+
+
+ Close
+
+
+ >
+ ) : (
+ <>
+
+
+ Create New Server Wallet Configuration?
+
+
+
+
+ This will create a completely new server wallet
+ configuration for your project.
+
+
+
+ Warning: This action cannot be undone
+
+
+
+ Any existing server wallets will be permanently
+ inaccessible
+
+
+ You will need to create new wallets after this
+ process
+
+
+
+
+ {wasManagedVault && (
+
+
+ setSecretKeyInput(e.target.value)}
+ />
+
+ Your secret key is required to create a managed
+ vault.
+
+
+ )}
+
+
+
+ setConfirmed(checked === true)
+ }
+ />
+
+
- )}
-
-
-
- setConfirmed(checked === true)
- }
- />
-
-
-
-
-
-
- {
- setConfirmed(false);
- setSecretKeyInput("");
- }}
- >
- Cancel
-
- {
- e.preventDefault();
- regenerateMutation.mutate();
- }}
- variant="destructive"
- className="gap-2"
- >
- {regenerateMutation.isPending && }
- Create New Wallet Configuration
-
-
+
+
+
+ {
+ setConfirmed(false);
+ setSecretKeyInput("");
+ }}
+ >
+ Cancel
+
+ {
+ e.preventDefault();
+ regenerateMutation.mutate();
+ }}
+ variant="destructive"
+ className="gap-2"
+ >
+ {regenerateMutation.isPending && (
+
+ )}
+ Create New Wallet Configuration
+
+
+ >
+ )}
From da1a12dde6f88426c02edccd1fc93a3287ad3271 Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Mon, 12 Jan 2026 18:50:04 +0700
Subject: [PATCH 3/5] Add retry logic to project credentials update
Introduces a withRetry function with exponential backoff and applies it to the updateProjectClient call in createAndEncryptVaultAccessTokens. This ensures critical credential updates are retried on failure, reducing risk of unrecoverable project state.
---
.../transactions/lib/vault.client.ts | 73 +++++++++++++------
1 file changed, 52 insertions(+), 21 deletions(-)
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
index 52757021507..15189186a2c 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
@@ -21,6 +21,32 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
"Management Token for Dashboard";
+/**
+ * Retry a function with exponential backoff.
+ */
+async function withRetry(
+ fn: () => Promise,
+ options: { maxAttempts?: number; baseDelayMs?: number } = {},
+): Promise {
+ const { maxAttempts = 3, baseDelayMs = 1000 } = options;
+ let lastError: Error | undefined;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ return await fn();
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+ if (attempt < maxAttempts) {
+ // Exponential backoff: 1s, 2s, 4s...
+ const delay = baseDelayMs * 2 ** (attempt - 1);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+ }
+
+ throw lastError;
+}
+
let vc: VaultClient | null = null;
export async function initVaultClient() {
@@ -300,29 +326,34 @@ async function createAndEncryptVaultAccessTokens(props: {
projectWalletAddress = defaultProjectServerWallet.address;
}
- // Save credentials
- await updateProjectClient(
- {
- projectId: props.project.id,
- teamId: props.project.teamId,
- },
- {
- services: [
- ...props.project.services.filter(
- (service) => service.name !== "engineCloud",
- ),
+ // Save credentials with retry - this is critical because if rotation succeeded
+ // but this save fails, the new rotation code is lost and project becomes unrecoverable
+ await withRetry(
+ () =>
+ updateProjectClient(
+ {
+ projectId: props.project.id,
+ teamId: props.project.teamId,
+ },
{
- name: "engineCloud",
- actions: [],
- managementAccessToken: managementToken.accessToken,
- maskedAdminKey: maskSecret(adminKey),
- encryptedAdminKey,
- encryptedWalletAccessToken,
- rotationCode: rotationCode,
- projectWalletAddress: projectWalletAddress ?? null,
+ services: [
+ ...props.project.services.filter(
+ (service) => service.name !== "engineCloud",
+ ),
+ {
+ name: "engineCloud",
+ actions: [],
+ managementAccessToken: managementToken.accessToken,
+ maskedAdminKey: maskSecret(adminKey),
+ encryptedAdminKey,
+ encryptedWalletAccessToken,
+ rotationCode: rotationCode,
+ projectWalletAddress: projectWalletAddress ?? null,
+ },
+ ],
},
- ],
- },
+ ),
+ { maxAttempts: 3, baseDelayMs: 1000 },
);
return {
From b69756798408ebbd4c09ac5d655d120e74bd2663 Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Mon, 12 Jan 2026 18:58:43 +0700
Subject: [PATCH 4/5] Refactor vault token creation and retry logic
Added validation for maxAttempts in withRetry to ensure it is at least 1. Refactored createAndEncryptVaultAccessTokens to use destructured props and improved handling of projectSecretHash and project properties for consistency.
---
.../(sidebar)/transactions/lib/vault.client.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
index 15189186a2c..687220c6f9a 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
@@ -29,6 +29,9 @@ async function withRetry(
options: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise {
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
+ if (maxAttempts < 1) {
+ throw new Error("maxAttempts must be at least 1");
+ }
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -257,6 +260,7 @@ async function createAndEncryptVaultAccessTokens(props: {
const {
project,
projectSecretKey,
+ projectSecretHash,
vaultClient,
adminKey,
rotationCode,
@@ -297,7 +301,7 @@ async function createAndEncryptVaultAccessTokens(props: {
const secretKeysHashed = [
...project.secretKeys,
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
- ...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
+ ...(projectSecretHash ? [{ hash: projectSecretHash }] : []),
];
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
throw new Error("Invalid project secret key");
@@ -332,12 +336,12 @@ async function createAndEncryptVaultAccessTokens(props: {
() =>
updateProjectClient(
{
- projectId: props.project.id,
- teamId: props.project.teamId,
+ projectId: project.id,
+ teamId: project.teamId,
},
{
services: [
- ...props.project.services.filter(
+ ...project.services.filter(
(service) => service.name !== "engineCloud",
),
{
From d289f145c818c06f076cfd3018b8b337e0d0fb1b Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Mon, 12 Jan 2026 19:13:21 +0700
Subject: [PATCH 5/5] Improve withRetry validation and add secret key check
Enhanced the withRetry function to validate input parameters more strictly and add jitter to exponential backoff. Added a pre-rotation secret key validation in rotateVaultAccountAndAccessToken to prevent project lockout if the provided secret key is invalid.
---
.../transactions/lib/vault.client.ts | 30 +++++++++++++++----
1 file changed, 25 insertions(+), 5 deletions(-)
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
index 687220c6f9a..a0b0445e30b 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client.ts
@@ -29,9 +29,12 @@ async function withRetry(
options: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise {
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
- if (maxAttempts < 1) {
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new Error("maxAttempts must be at least 1");
}
+ if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
+ throw new Error("baseDelayMs must be a non-negative number");
+ }
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -40,14 +43,17 @@ async function withRetry(
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < maxAttempts) {
- // Exponential backoff: 1s, 2s, 4s...
- const delay = baseDelayMs * 2 ** (attempt - 1);
- await new Promise((resolve) => setTimeout(resolve, delay));
+ // Exponential backoff with cap at 30s and jitter to prevent thundering herd
+ const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000);
+ const jitter = Math.floor(Math.random() * Math.min(250, delay));
+ await new Promise((resolve) =>
+ setTimeout(resolve, delay + jitter),
+ );
}
}
}
- throw lastError;
+ throw lastError ?? new Error("withRetry failed without capturing an error");
}
let vc: VaultClient | null = null;
@@ -76,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
throw new Error("No rotation code found");
}
+ // IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
+ // If we rotate first and then secret key validation fails, the old rotation code
+ // is consumed but we can't save the new one, leaving the project unrecoverable.
+ if (props.projectSecretKey) {
+ const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey);
+ const secretKeysHashed = [
+ ...props.project.secretKeys,
+ ...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
+ ];
+ if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
+ throw new Error("Invalid project secret key");
+ }
+ }
+
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {