@@ -390,14 +390,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
390390 workflowRecord . workspaceId || undefined
391391 )
392392
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 }
398- )
399- }
400-
401393 let externalSubscriptionCreated = false
402394 const createTempWebhookData = ( providerConfigOverride = resolvedProviderConfig ) => ( {
403395 id : targetWebhookId || generateShortId ( ) ,
@@ -418,6 +410,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
418410 existingWebhook = existingRows [ 0 ] || null
419411 }
420412
413+ /**
414+ * permission-group-enforced: triggers.webhook — a raw upsert handler with no
415+ * application operation to declare the capability on, so it is asserted
416+ * here.
417+ *
418+ * Creation and reactivation, because both end with a workflow newly
419+ * reachable from an inbound webhook: this upsert always writes
420+ * `isActive: true`, so re-saving a dormant webhook turns it back on exactly
421+ * as `PATCH /api/webhooks/[id]` would, and that route already gates the same
422+ * transition.
423+ *
424+ * Re-saving an already-active webhook is not gated. It changes the config of
425+ * an endpoint that is already reachable and adds no exposure, and refusing
426+ * it would strand a member unable to repair a live integration — the same
427+ * reason inbound delivery is never gated. Inbound delivery runs with no
428+ * session to resolve a group against, and refusing there would break live
429+ * integrations at the provider rather than in Sim. Removing existing
430+ * exposure stays a deliberate act of deleting or deactivating the webhook.
431+ */
432+ if ( ! existingWebhook || existingWebhook . isActive === false ) {
433+ const withheld = workflowRecord . workspaceId
434+ ? await isWorkspaceCapabilityWithheld (
435+ userId ,
436+ workflowRecord . workspaceId ,
437+ 'triggers.webhook'
438+ )
439+ : false
440+ if ( withheld ) {
441+ logger . warn (
442+ `[${ requestId } ] Webhook ${ existingWebhook ? 'reactivation' : 'creation' } blocked by permission group` ,
443+ {
444+ userId,
445+ workflowId,
446+ }
447+ )
448+ return NextResponse . json ( { error : capabilityRefusal ( 'triggers.webhook' ) } , { status : 403 } )
449+ }
450+ }
451+
421452 const shouldRecreateSubscription =
422453 existingWebhook &&
423454 shouldRecreateExternalWebhookSubscription ( {
@@ -428,6 +459,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
428459 nextConfig : resolvedProviderConfig ,
429460 } )
430461
462+ /** The row stores the unresolved text, so only a literal credential id can be authorized. */
463+ if ( resolvedProviderConfig . credentialId !== originalProviderConfig . credentialId ) {
464+ return NextResponse . json (
465+ { error : 'providerConfig.credentialId must be a literal credential id' } ,
466+ { status : 400 }
467+ )
468+ }
469+
431470 /**
432471 * Subscription handlers, pollers, and subscription cleanup look `credentialId`
433472 * up by id alone and mint tokens as its owner, so every credential this save
@@ -467,45 +506,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
467506 }
468507 }
469508
470- /**
471- * permission-group-enforced: triggers.webhook — a raw upsert handler with no
472- * application operation to declare the capability on, so it is asserted
473- * here.
474- *
475- * Creation and reactivation, because both end with a workflow newly
476- * reachable from an inbound webhook: this upsert always writes
477- * `isActive: true`, so re-saving a dormant webhook turns it back on exactly
478- * as `PATCH /api/webhooks/[id]` would, and that route already gates the same
479- * transition.
480- *
481- * Re-saving an already-active webhook is not gated. It changes the config of
482- * an endpoint that is already reachable and adds no exposure, and refusing
483- * it would strand a member unable to repair a live integration — the same
484- * reason inbound delivery is never gated. Inbound delivery runs with no
485- * session to resolve a group against, and refusing there would break live
486- * integrations at the provider rather than in Sim. Removing existing
487- * exposure stays a deliberate act of deleting or deactivating the webhook.
488- */
489- if ( ! existingWebhook || existingWebhook . isActive === false ) {
490- const withheld = workflowRecord . workspaceId
491- ? await isWorkspaceCapabilityWithheld (
492- userId ,
493- workflowRecord . workspaceId ,
494- 'triggers.webhook'
495- )
496- : false
497- if ( withheld ) {
498- logger . warn (
499- `[${ requestId } ] Webhook ${ existingWebhook ? 'reactivation' : 'creation' } blocked by permission group` ,
500- {
501- userId,
502- workflowId,
503- }
504- )
505- return NextResponse . json ( { error : capabilityRefusal ( 'triggers.webhook' ) } , { status : 403 } )
506- }
507- }
508-
509509 if ( ! existingWebhook || shouldRecreateSubscription ) {
510510 try {
511511 const result = await createExternalWebhookSubscription (
0 commit comments