@@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
2121export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
2222 "Management Token for Dashboard" ;
2323
24+ /**
25+ * Retry a function with exponential backoff.
26+ */
27+ async function withRetry < T > (
28+ fn : ( ) => Promise < T > ,
29+ options : { maxAttempts ?: number ; baseDelayMs ?: number } = { } ,
30+ ) : Promise < T > {
31+ const { maxAttempts = 3 , baseDelayMs = 1000 } = options ;
32+ if ( ! Number . isInteger ( maxAttempts ) || maxAttempts < 1 ) {
33+ throw new Error ( "maxAttempts must be at least 1" ) ;
34+ }
35+ if ( ! Number . isFinite ( baseDelayMs ) || baseDelayMs < 0 ) {
36+ throw new Error ( "baseDelayMs must be a non-negative number" ) ;
37+ }
38+ let lastError : Error | undefined ;
39+
40+ for ( let attempt = 1 ; attempt <= maxAttempts ; attempt ++ ) {
41+ try {
42+ return await fn ( ) ;
43+ } catch ( error ) {
44+ lastError = error instanceof Error ? error : new Error ( String ( error ) ) ;
45+ if ( attempt < maxAttempts ) {
46+ // Exponential backoff with cap at 30s and jitter to prevent thundering herd
47+ const delay = Math . min ( baseDelayMs * 2 ** ( attempt - 1 ) , 30_000 ) ;
48+ const jitter = Math . floor ( Math . random ( ) * Math . min ( 250 , delay ) ) ;
49+ await new Promise < void > ( ( resolve ) =>
50+ setTimeout ( resolve , delay + jitter ) ,
51+ ) ;
52+ }
53+ }
54+ }
55+
56+ throw lastError ?? new Error ( "withRetry failed without capturing an error" ) ;
57+ }
58+
2459let vc : VaultClient | null = null ;
2560
2661export async function initVaultClient ( ) {
@@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
4782 throw new Error ( "No rotation code found" ) ;
4883 }
4984
85+ // IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
86+ // If we rotate first and then secret key validation fails, the old rotation code
87+ // is consumed but we can't save the new one, leaving the project unrecoverable.
88+ if ( props . projectSecretKey ) {
89+ const projectSecretKeyHash = await hashSecretKey ( props . projectSecretKey ) ;
90+ const secretKeysHashed = [
91+ ...props . project . secretKeys ,
92+ ...( props . projectSecretHash ? [ { hash : props . projectSecretHash } ] : [ ] ) ,
93+ ] ;
94+ if ( ! secretKeysHashed . some ( ( key ) => key ?. hash === projectSecretKeyHash ) ) {
95+ throw new Error ( "Invalid project secret key" ) ;
96+ }
97+ }
98+
5099 const rotateServiceAccountRes = await rotateServiceAccount ( {
51100 client : vaultClient ,
52101 request : {
@@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
69118 vaultClient,
70119 adminKey,
71120 rotationCode,
121+ // Skip wallet creation on rotation - preserve the existing project wallet
122+ skipWalletCreation : true ,
123+ existingProjectWalletAddress : service ?. projectWalletAddress ?? undefined ,
72124 } ) ;
73125
74126 return {
@@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: {
222274 projectSecretHash ?: string ;
223275 adminKey : string ;
224276 rotationCode : string ;
277+ skipWalletCreation ?: boolean ;
278+ existingProjectWalletAddress ?: string ;
225279} ) {
226- const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
227- props ;
280+ const {
281+ project,
282+ projectSecretKey,
283+ projectSecretHash,
284+ vaultClient,
285+ adminKey,
286+ rotationCode,
287+ skipWalletCreation,
288+ existingProjectWalletAddress,
289+ } = props ;
228290
229291 const [ managementTokenResult , walletTokenResult ] = await Promise . all ( [
230292 createManagementAccessToken ( { project, adminKey, vaultClient } ) ,
@@ -246,81 +308,78 @@ async function createAndEncryptVaultAccessTokens(props: {
246308 const managementToken = managementTokenResult . data ;
247309 const walletToken = walletTokenResult . data ;
248310
249- // create a default project server wallet
250- const defaultProjectServerWallet = await createProjectServerWallet ( {
251- project ,
252- managementAccessToken : managementToken . accessToken ,
253- label : getProjectWalletLabel ( project . name ) ,
254- } ) ;
311+ // CRITICAL: Save credentials IMMEDIATELY after creating tokens.
312+ // This prevents a broken state if wallet creation or other operations fail.
313+ // The rotationCode is consumed when rotating, so if we don't save the new one ,
314+ // the project becomes unrecoverable.
315+ let encryptedAdminKey : string | null = null ;
316+ let encryptedWalletAccessToken : string | null = null ;
255317
256318 if ( projectSecretKey ) {
257319 // verify that the project secret key is valid
258320 const projectSecretKeyHash = await hashSecretKey ( projectSecretKey ) ;
259321 const secretKeysHashed = [
260322 ...project . secretKeys ,
261323 // for newly rotated secret keys, we don't have the secret key in the project secret keys yet
262- ...( props . projectSecretHash ? [ { hash : props . projectSecretHash } ] : [ ] ) ,
324+ ...( projectSecretHash ? [ { hash : projectSecretHash } ] : [ ] ) ,
263325 ] ;
264326 if ( ! secretKeysHashed . some ( ( key ) => key ?. hash === projectSecretKeyHash ) ) {
265327 throw new Error ( "Invalid project secret key" ) ;
266328 }
267329
268330 // encrypt admin key and wallet token with project secret key
269- const [ encryptedAdminKey , encryptedWalletAccessToken ] = await Promise . all ( [
331+ [ encryptedAdminKey , encryptedWalletAccessToken ] = await Promise . all ( [
270332 encrypt ( adminKey , projectSecretKey ) ,
271333 encrypt ( walletToken . accessToken , projectSecretKey ) ,
272334 ] ) ;
335+ }
273336
274- await updateProjectClient (
275- {
276- projectId : props . project . id ,
277- teamId : props . project . teamId ,
278- } ,
279- {
280- services : [
281- ...props . project . services . filter (
282- ( service ) => service . name !== "engineCloud" ,
283- ) ,
284- {
285- name : "engineCloud" ,
286- actions : [ ] ,
287- managementAccessToken : managementToken . accessToken ,
288- maskedAdminKey : maskSecret ( adminKey ) ,
289- encryptedAdminKey,
290- encryptedWalletAccessToken,
291- rotationCode : rotationCode ,
292- projectWalletAddress : defaultProjectServerWallet . address ,
293- } ,
294- ] ,
295- } ,
296- ) ;
297- } else {
298- // no secret key, only store the management token, remove any encrypted keys
299- await updateProjectClient (
300- {
301- projectId : props . project . id ,
302- teamId : props . project . teamId ,
303- } ,
304- {
305- services : [
306- ...props . project . services . filter (
307- ( service ) => service . name !== "engineCloud" ,
308- ) ,
309- {
310- name : "engineCloud" ,
311- actions : [ ] ,
312- managementAccessToken : managementToken . accessToken ,
313- maskedAdminKey : maskSecret ( adminKey ) ,
314- encryptedAdminKey : null ,
315- encryptedWalletAccessToken : null ,
316- rotationCode : rotationCode ,
317- projectWalletAddress : defaultProjectServerWallet . address ,
318- } ,
319- ] ,
320- } ,
321- ) ;
337+ // For rotation, preserve existing wallet address. For new creation, create a default wallet.
338+ let projectWalletAddress : string | null | undefined =
339+ existingProjectWalletAddress ??
340+ project . services . find ( ( s ) => s . name === "engineCloud" )
341+ ?. projectWalletAddress ;
342+
343+ // Only create a new wallet if we don't have one (initial setup, not rotation)
344+ if ( ! skipWalletCreation && ! projectWalletAddress ) {
345+ const defaultProjectServerWallet = await createProjectServerWallet ( {
346+ project,
347+ managementAccessToken : managementToken . accessToken ,
348+ label : getProjectWalletLabel ( project . name ) ,
349+ } ) ;
350+ projectWalletAddress = defaultProjectServerWallet . address ;
322351 }
323352
353+ // Save credentials with retry - this is critical because if rotation succeeded
354+ // but this save fails, the new rotation code is lost and project becomes unrecoverable
355+ await withRetry (
356+ ( ) =>
357+ updateProjectClient (
358+ {
359+ projectId : project . id ,
360+ teamId : project . teamId ,
361+ } ,
362+ {
363+ services : [
364+ ...project . services . filter (
365+ ( service ) => service . name !== "engineCloud" ,
366+ ) ,
367+ {
368+ name : "engineCloud" ,
369+ actions : [ ] ,
370+ managementAccessToken : managementToken . accessToken ,
371+ maskedAdminKey : maskSecret ( adminKey ) ,
372+ encryptedAdminKey,
373+ encryptedWalletAccessToken,
374+ rotationCode : rotationCode ,
375+ projectWalletAddress : projectWalletAddress ?? null ,
376+ } ,
377+ ] ,
378+ } ,
379+ ) ,
380+ { maxAttempts : 3 , baseDelayMs : 1000 } ,
381+ ) ;
382+
324383 return {
325384 managementToken,
326385 walletToken,
0 commit comments