@@ -379,7 +379,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
379379 let existingWebhook : any = null
380380 /**
381381 * `userId` is server-owned: the polling token resolver falls back to that
382- * user's own OAuth account when no credential is set.
382+ * user's own OAuth account when no credential is set. It is neither accepted
383+ * from the client nor carried forward from a stored row; Gmail and Outlook
384+ * polling setup derive it again from the credential after the save.
383385 */
384386 const originalProviderConfig : Record < string , unknown > = omit ( providerConfig || { } , [ 'userId' ] )
385387 let resolvedProviderConfig = await resolveEnvVarsInObject (
@@ -388,36 +390,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
388390 workflowRecord . workspaceId || undefined
389391 )
390392
391- /**
392- * Subscription handlers and pollers look `credentialId` up by id alone and
393- * mint tokens as its owner, so the actor must be able to use it in the
394- * workflow's workspace before anything is subscribed or saved.
395- */
396- const requestedCredentialId = originalProviderConfig . credentialId
397- if ( requestedCredentialId != null && requestedCredentialId !== '' ) {
398- /** The row stores the unresolved text, so only a literal id is what gets authorized. */
399- if (
400- typeof requestedCredentialId !== 'string' ||
401- resolvedProviderConfig . credentialId !== requestedCredentialId
402- ) {
403- return NextResponse . json (
404- { error : 'providerConfig.credentialId must be a literal credential id' } ,
405- { status : 400 }
406- )
407- }
408- const credentialAccess = await authorizeCredentialUseForAuth (
409- { success : true , userId, authType : AuthType . SESSION } ,
410- { credentialId : requestedCredentialId , workflowId }
393+ /** The row stores the unresolved text, so only a literal credential id can be authorized. */
394+ if ( resolvedProviderConfig . credentialId !== originalProviderConfig . credentialId ) {
395+ return NextResponse . json (
396+ { error : 'providerConfig.credentialId must be a literal credential id' } ,
397+ { status : 400 }
411398 )
412- if ( ! credentialAccess . ok ) {
413- logger . warn ( `[${ requestId } ] Webhook credential reference denied` , {
414- userId,
415- workflowId,
416- credentialId : requestedCredentialId ,
417- reason : credentialAccess . error ,
418- } )
419- return NextResponse . json ( { error : credentialAccess . error } , { status : 403 } )
420- }
421399 }
422400
423401 let externalSubscriptionCreated = false
@@ -440,6 +418,39 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
440418 existingWebhook = existingRows [ 0 ] || null
441419 }
442420
421+ /**
422+ * Subscription handlers, pollers, and subscription cleanup look `credentialId`
423+ * up by id alone and mint tokens as its owner. A save acts with the requested
424+ * credential or, when the request omits it, the stored one, so that credential
425+ * must be usable by the actor in the workflow's workspace before anything is
426+ * subscribed, cleaned up, or saved.
427+ */
428+ const effectiveCredentialId =
429+ 'credentialId' in originalProviderConfig
430+ ? originalProviderConfig . credentialId
431+ : existingWebhook ?. providerConfig ?. credentialId
432+ if ( effectiveCredentialId != null && effectiveCredentialId !== '' ) {
433+ if ( typeof effectiveCredentialId !== 'string' ) {
434+ return NextResponse . json (
435+ { error : 'providerConfig.credentialId must be a literal credential id' } ,
436+ { status : 400 }
437+ )
438+ }
439+ const credentialAccess = await authorizeCredentialUseForAuth (
440+ { success : true , userId, authType : AuthType . SESSION } ,
441+ { credentialId : effectiveCredentialId , workflowId }
442+ )
443+ if ( ! credentialAccess . ok ) {
444+ logger . warn ( `[${ requestId } ] Webhook credential reference denied` , {
445+ userId,
446+ workflowId,
447+ credentialId : effectiveCredentialId ,
448+ reason : credentialAccess . error ,
449+ } )
450+ return NextResponse . json ( { error : credentialAccess . error } , { status : 403 } )
451+ }
452+ }
453+
443454 /**
444455 * permission-group-enforced: triggers.webhook — a raw upsert handler with no
445456 * application operation to declare the capability on, so it is asserted
@@ -519,6 +530,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
519530 userProvided
520531 )
521532 }
533+ configToSave . userId = undefined
522534
523535 try {
524536 if ( targetWebhookId ) {
0 commit comments