@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22import { db } from '@sim/db'
33import { account , credential , credentialMember , workspace } from '@sim/db/schema'
44import { createLogger } from '@sim/logger'
5+ import { getPostgresErrorCode } from '@sim/utils/errors'
56import { generateId } from '@sim/utils/id'
67import { and , eq } from 'drizzle-orm'
78import { type NextRequest , NextResponse } from 'next/server'
@@ -33,6 +34,19 @@ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
3334
3435const logger = createLogger ( 'CredentialsAPI' )
3536
37+ /**
38+ * Thrown by the inner duplicate guard inside the create transaction when a
39+ * concurrent request slipped a row in between the outer existence check and
40+ * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can
41+ * map to a friendly message.
42+ */
43+ class DuplicateCredentialError extends Error {
44+ constructor ( ) {
45+ super ( 'duplicate_display_name' )
46+ this . name = 'DuplicateCredentialError'
47+ }
48+ }
49+
3650interface ExistingCredentialSourceParams {
3751 workspaceId : string
3852 type : 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
@@ -43,11 +57,16 @@ interface ExistingCredentialSourceParams {
4357 providerId ?: string | null
4458}
4559
46- async function findExistingCredentialBySource ( params : ExistingCredentialSourceParams ) {
60+ type DbOrTx = typeof db | Parameters < Parameters < typeof db . transaction > [ 0 ] > [ 0 ]
61+
62+ async function findExistingCredentialBySourceWith (
63+ exec : DbOrTx ,
64+ params : ExistingCredentialSourceParams
65+ ) {
4766 const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
4867
4968 if ( type === 'oauth' && accountId ) {
50- const [ row ] = await db
69+ const [ row ] = await exec
5170 . select ( )
5271 . from ( credential )
5372 . where (
@@ -62,7 +81,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
6281 }
6382
6483 if ( type === 'env_workspace' && envKey ) {
65- const [ row ] = await db
84+ const [ row ] = await exec
6685 . select ( )
6786 . from ( credential )
6887 . where (
@@ -77,7 +96,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
7796 }
7897
7998 if ( type === 'env_personal' && envKey && envOwnerUserId ) {
80- const [ row ] = await db
99+ const [ row ] = await exec
81100 . select ( )
82101 . from ( credential )
83102 . where (
@@ -93,7 +112,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
93112 }
94113
95114 if ( type === 'service_account' && displayName && providerId ) {
96- const [ row ] = await db
115+ const [ row ] = await exec
97116 . select ( )
98117 . from ( credential )
99118 . where (
@@ -111,6 +130,17 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
111130 return null
112131}
113132
133+ async function findExistingCredentialBySource ( params : ExistingCredentialSourceParams ) {
134+ return findExistingCredentialBySourceWith ( db , params )
135+ }
136+
137+ async function findExistingCredentialBySourceTx (
138+ tx : Parameters < Parameters < typeof db . transaction > [ 0 ] > [ 0 ] ,
139+ params : ExistingCredentialSourceParams
140+ ) {
141+ return findExistingCredentialBySourceWith ( tx , params )
142+ }
143+
114144export const GET = withRouteHandler ( async ( request : NextRequest ) => {
115145 const requestId = generateRequestId ( )
116146 const session = await getSession ( )
@@ -278,6 +308,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
278308 const resolvedEnvKey : string | null = envKey ? normalizeCredentialEnvKey ( envKey ) : null
279309 let resolvedEnvOwnerUserId : string | null = null
280310 let resolvedEncryptedServiceAccountKey : string | null = null
311+ const extraAuditMetadata : Record < string , unknown > = { }
281312
282313 if ( type === 'oauth' ) {
283314 const [ accountRow ] = await db
@@ -341,6 +372,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
341372 } )
342373 const { encrypted } = await encryptSecret ( blob )
343374 resolvedEncryptedServiceAccountKey = encrypted
375+ extraAuditMetadata . atlassianDomain = normalizedDomain
376+ extraAuditMetadata . atlassianCloudId = validation . cloudId
344377 } else {
345378 if ( ! serviceAccountJson ) {
346379 return NextResponse . json (
@@ -472,6 +505,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
472505 . limit ( 1 )
473506
474507 await db . transaction ( async ( tx ) => {
508+ // Race-safety re-check: the outer findExistingCredentialBySource ran outside
509+ // the transaction. Service_account rows have no DB-level unique index on
510+ // (workspaceId, providerId, displayName), so a concurrent request could
511+ // have inserted a duplicate after our outer check but before this insert.
512+ // Re-check here and abort the tx if so. The catch maps this to a 409.
513+ const innerExisting = await findExistingCredentialBySourceTx ( tx , {
514+ workspaceId,
515+ type,
516+ accountId : resolvedAccountId ,
517+ envKey : resolvedEnvKey ,
518+ envOwnerUserId : resolvedEnvOwnerUserId ,
519+ displayName : resolvedDisplayName ,
520+ providerId : resolvedProviderId ,
521+ } )
522+ if ( innerExisting ) throw new DuplicateCredentialError ( )
523+
475524 await tx . insert ( credential ) . values ( {
476525 id : credentialId ,
477526 workspaceId,
@@ -552,12 +601,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
552601 metadata : {
553602 credentialType : type ,
554603 providerId : resolvedProviderId ,
604+ ...extraAuditMetadata ,
555605 } ,
556606 request,
557607 } )
558608
559609 return NextResponse . json ( { credential : created } , { status : 201 } )
560- } catch ( error : any ) {
610+ } catch ( error : unknown ) {
561611 if ( error instanceof AtlassianValidationError ) {
562612 logger . warn ( `[${ requestId } ] Atlassian credential rejected: ${ error . code } ` , {
563613 code : error . code ,
@@ -566,30 +616,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
566616 } )
567617 return NextResponse . json ( { code : error . code , error : error . code } , { status : 400 } )
568618 }
569- if ( error ?. code === '23505' ) {
619+ if ( error instanceof DuplicateCredentialError ) {
620+ return NextResponse . json (
621+ {
622+ code : 'duplicate_display_name' ,
623+ error : 'A credential with that name already exists in this workspace.' ,
624+ } ,
625+ { status : 409 }
626+ )
627+ }
628+ const pgCode = getPostgresErrorCode ( error )
629+ if ( pgCode === '23505' ) {
570630 return NextResponse . json (
571631 { error : 'A credential with this source already exists' } ,
572632 { status : 409 }
573633 )
574634 }
575- if ( error ?. code === '23503' ) {
635+ if ( pgCode === '23503' ) {
576636 return NextResponse . json (
577637 { error : 'Invalid credential reference or membership target' } ,
578638 { status : 400 }
579639 )
580640 }
581- if ( error ?. code === '23514' ) {
641+ if ( pgCode === '23514' ) {
582642 return NextResponse . json (
583643 { error : 'Credential source data failed validation checks' } ,
584644 { status : 400 }
585645 )
586646 }
647+ const errAsRecord =
648+ typeof error === 'object' && error !== null ? ( error as Record < string , unknown > ) : { }
587649 logger . error ( `[${ requestId } ] Credential create failure details` , {
588- code : error ?. code ,
589- detail : error ? .detail ,
590- constraint : error ? .constraint ,
591- table : error ? .table ,
592- message : error ? .message ,
650+ code : pgCode ,
651+ detail : errAsRecord . detail ,
652+ constraint : errAsRecord . constraint ,
653+ table : errAsRecord . table ,
654+ message : errAsRecord . message ,
593655 } )
594656 logger . error ( `[${ requestId } ] Failed to create credential` , error )
595657 return NextResponse . json ( { error : 'Internal server error' } , { status : 500 } )
0 commit comments