diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Continuation Request.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Continuation Request.bru index 01d39ebf3f..9859237b18 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Continuation Request.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Continuation Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/continue/{{continueId}} + url: {{senderOpenPaymentsContinuationUri}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru index 5b9b0277a9..b8db3b4491 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru @@ -37,10 +37,21 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if ( + resourceUrl.hostname.includes('cloud-nine-wallet') || + resourceUrl.hostname.includes('happy-life-bank') + ){ + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? 3000 : 4000 + bru.setEnvVar("receiverOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("receiverOpenPaymentsHost", body?.resourceServer); + } } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru index 384cfa6282..b3bf949a66 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru @@ -28,7 +28,7 @@ script:post-response { } const body = res.getBody() - + bru.setEnvVar("senderAssetCode", body?.assetCode) bru.setEnvVar("senderAssetScale", body?.assetScale) @@ -38,10 +38,18 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if (resourceUrl.hostname.includes('cloud-nine-wallet') || resourceUrl.hostname.includes('happy-life-bank')) { + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? bru.getEnvVar('senderOpenPaymentsPort') : bru.getEnvVar('receiverOpenPaymentsPort') + bru.setEnvVar("senderOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("senderOpenPaymentsHost", body?.resourceServer); + } } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru index 5c8213f66a..662624944d 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } @@ -21,8 +21,7 @@ body:json { ], "identifier": "{{senderWalletAddress}}", "limits": { - "debitAmount": {{debitAmount}}, - "receiveAmount": {{receiveAmount}} + "debitAmount": {{debitAmount}} } } ] @@ -47,6 +46,9 @@ script:post-response { const scripts = require('./scripts'); scripts.storeTokenDetails(); + + const body = res.getBody() + bru.setEnvVar("senderOpenPaymentsContinuationUri", body?.continue.uri) } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Continuation Request.bru b/bruno/collections/Rafiki/Examples/Open Payments/Continuation Request.bru index 01d39ebf3f..9859237b18 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Continuation Request.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Continuation Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/continue/{{continueId}} + url: {{senderOpenPaymentsContinuationUri}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru index afc6464ee6..093a481bae 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru @@ -41,7 +41,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Create Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Create Outgoing Payment.bru index e14eaa6536..6014dda378 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Create Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Create Outgoing Payment.bru @@ -34,7 +34,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("outgoingPaymentId", body.id.split("/").pop()); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru index 9e5acc50c4..b8db3b4491 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru @@ -22,11 +22,11 @@ script:pre-request { script:post-response { const url = require('url') - + if (res.getStatus() !== 200) { return } - + const body = res.getBody() bru.setEnvVar("receiverAssetCode", body?.assetCode) bru.setEnvVar("receiverAssetScale", body?.assetScale) @@ -37,10 +37,21 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if ( + resourceUrl.hostname.includes('cloud-nine-wallet') || + resourceUrl.hostname.includes('happy-life-bank') + ){ + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? 3000 : 4000 + bru.setEnvVar("receiverOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("receiverOpenPaymentsHost", body?.resourceServer); + } } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru index 9665a40e32..f42f5d53bb 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru @@ -37,10 +37,18 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if (resourceUrl.hostname.includes('cloud-nine-wallet') || resourceUrl.hostname.includes('happy-life-bank')) { + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? bru.getEnvVar('senderOpenPaymentsPort') : bru.getEnvVar('receiverOpenPaymentsPort') + bru.setEnvVar("senderOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("senderOpenPaymentsHost", body?.resourceServer); + } } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru index 3a4e984bae..3c4b667956 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } @@ -46,6 +46,9 @@ script:post-response { const scripts = require('./scripts'); scripts.storeTokenDetails(); + + const body = res.getBody() + bru.setEnvVar("senderOpenPaymentsContinuationUri", body?.continue.uri) } tests { diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru index 3c0736670d..a478247fea 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru new file mode 100644 index 0000000000..39f376db73 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Continuation Request.bru @@ -0,0 +1,33 @@ +meta { + name: Continuation Request + type: http + seq: 8 +} + +post { + url: {{senderTenantOpenPaymentsContinuationUri}} + body: json + auth: none +} + +headers { + Authorization: GNAP {{continueToken}} +} + +script:pre-request { + const scripts = require('./scripts'); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const scripts = require('./scripts'); + + scripts.storeTokenDetails(); +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Incoming Payment.bru new file mode 100644 index 0000000000..093a481bae --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Incoming Payment.bru @@ -0,0 +1,55 @@ +meta { + name: Create Incoming Payment + type: http + seq: 4 +} + +post { + url: {{receiverOpenPaymentsHost}}/incoming-payments + body: json + auth: none +} + +headers { + Authorization: GNAP {{accessToken}} +} + +body:json { + { + "walletAddress": "{{receiverWalletAddress}}", + "incomingAmount": { + "value": "100", + "assetCode": "{{receiverAssetCode}}", + "assetScale": {{receiverAssetScale}} + }, + "expiresAt": "{{tomorrow}}", + "metadata": { + "description": "Free Money!" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + bru.setEnvVar("tomorrow", (new Date(new Date().setDate(new Date().getDate() + 1))).toISOString()); + + scripts.addHostHeader(); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.id) { + bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); + } + +} + +tests { + test("Status code is 201", function() { + expect(res.getStatus()).to.equal(201); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru new file mode 100644 index 0000000000..b8527f9042 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Outgoing Payment.bru @@ -0,0 +1,48 @@ +meta { + name: Create Outgoing Payment + type: http + seq: 9 +} + +post { + url: {{senderTenantOpenPaymentsHost}}/outgoing-payments + body: json + auth: none +} + +headers { + Authorization: GNAP {{accessToken}} +} + +body:json { + { + "walletAddress": "{{senderTenantWalletAddress}}", + "quoteId": "{{senderTenantWalletAddress}}/quotes/{{quoteId}}", + "metadata": { + "description": "Free Money!" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader(); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.id) { + bru.setEnvVar("outgoingPaymentId", body.id.split("/").pop()); + } + +} + +tests { + test("Status code is 201", function() { + expect(res.getStatus()).to.equal(201); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Quote.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Quote.bru new file mode 100644 index 0000000000..8bc7d378a2 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Create Quote.bru @@ -0,0 +1,47 @@ +meta { + name: Create Quote + type: http + seq: 6 +} + +post { + url: {{senderTenantOpenPaymentsHost}}/quotes + body: json + auth: none +} + +headers { + Authorization: GNAP {{accessToken}} +} + +body:json { + { + "walletAddress": "{{senderTenantWalletAddress}}", + "receiver": "{{receiverOpenPaymentsHost}}/incoming-payments/{{incomingPaymentId}}", + "method": "ilp" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader(); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const body = res.getBody(); + if (body?.id) { + bru.setEnvVar("quoteId", body.id.split("/").pop()); + bru.setEnvVar("quoteDebitAmount", JSON.stringify(body.debitAmount)) + bru.setEnvVar("quoteReceiveAmount", JSON.stringify(body.receiveAmount)) + } + +} + +tests { + test("Status code is 201", function() { + expect(res.getStatus()).to.equal(201); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru new file mode 100644 index 0000000000..17e3671e8c --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get Outgoing Payment.bru @@ -0,0 +1,29 @@ +meta { + name: Get Outgoing Payment + type: http + seq: 10 +} + +get { + url: {{senderTenantOpenPaymentsHost}}/outgoing-payments/{{outgoingPaymentId}} + body: none + auth: none +} + +headers { + Authorization: GNAP {{accessToken}} +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader(); + + await scripts.addSignatureHeaders(); +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get receiver wallet address.bru new file mode 100644 index 0000000000..b8db3b4491 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get receiver wallet address.bru @@ -0,0 +1,61 @@ +meta { + name: Get receiver wallet address + type: http + seq: 2 +} + +get { + url: {{receiverWalletAddress}} + body: none + auth: none +} + +headers { + Accept: application/json +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader("receiverOpenPaymentsHost"); +} + +script:post-response { + const url = require('url') + + if (res.getStatus() !== 200) { + return + } + + const body = res.getBody() + bru.setEnvVar("receiverAssetCode", body?.assetCode) + bru.setEnvVar("receiverAssetScale", body?.assetScale) + + const authUrl = url.parse(body?.authServer) + if ( + authUrl.hostname.includes('cloud-nine-wallet') || + authUrl.hostname.includes('happy-life-bank') + ){ + const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); + } else { + bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); + } + + const resourceUrl = url.parse(body?.resourceServer) + if ( + resourceUrl.hostname.includes('cloud-nine-wallet') || + resourceUrl.hostname.includes('happy-life-bank') + ){ + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? 3000 : 4000 + bru.setEnvVar("receiverOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("receiverOpenPaymentsHost", body?.resourceServer); + } +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get sender wallet address.bru new file mode 100644 index 0000000000..0a4d2a6507 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Get sender wallet address.bru @@ -0,0 +1,58 @@ +meta { + name: Get sender wallet address + type: http + seq: 1 +} + +get { + url: {{senderTenantWalletAddress}} + body: none + auth: none +} + +headers { + Accept: application/json +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addHostHeader("senderOpenPaymentsHost"); +} + +script:post-response { + const url = require('url') + + if (res.getStatus() !== 200) { + return + } + + const body = res.getBody() + bru.setEnvVar("senderAssetCode", body?.assetCode) + bru.setEnvVar("senderAssetScale", body?.assetScale) + + const authUrl = url.parse(body?.authServer) + if ( + authUrl.hostname.includes('cloud-nine-wallet') || + authUrl.hostname.includes('happy-life-bank') + ){ + const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 + bru.setEnvVar("senderTenantOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); + } else { + bru.setEnvVar("senderTenantOpenPaymentsAuthHost", body?.authServer); + } + + const resourceUrl = url.parse(body?.resourceServer) + if (resourceUrl.hostname.includes('cloud-nine-wallet') || resourceUrl.hostname.includes('happy-life-bank')) { + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? bru.getEnvVar('senderOpenPaymentsPort') : bru.getEnvVar('receiverOpenPaymentsPort') + bru.setEnvVar("senderTenantOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("senderTenantOpenPaymentsHost", body?.resourceServer); + } +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Incoming Payment.bru new file mode 100644 index 0000000000..f238b40b1a --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Incoming Payment.bru @@ -0,0 +1,45 @@ +meta { + name: Grant Request Incoming Payment + type: http + seq: 3 +} + +post { + url: {{receiverOpenPaymentsAuthHost}} + body: json + auth: none +} + +body:json { + { + "access_token": { + "access": [ + { + "type": "incoming-payment", + "actions": [ + "create", "read", "list", "complete" + ] + } + ] + }, + "client": "{{clientWalletAddress}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const scripts = require('./scripts'); + + scripts.storeTokenDetails(); +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Outgoing Payment.bru new file mode 100644 index 0000000000..68275ab376 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Outgoing Payment.bru @@ -0,0 +1,58 @@ +meta { + name: Grant Request Outgoing Payment + type: http + seq: 7 +} + +post { + url: {{senderTenantOpenPaymentsAuthHost}} + body: json + auth: none +} + +body:json { + { + "access_token": { + "access": [ + { + "type": "outgoing-payment", + "actions": [ + "create", "read", "list" + ], + "identifier": "{{senderTenantWalletAddress}}", + "limits": { + "debitAmount": {{quoteDebitAmount}} + } + } + ] + }, + "client": "{{clientWalletAddress}}", + "interact": { + "start": [ + "redirect" + ] + } + } + +} + +script:pre-request { + const scripts = require('./scripts'); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const scripts = require('./scripts'); + + scripts.storeTokenDetails(); + + const body = res.getBody() + bru.setEnvVar("senderTenantOpenPaymentsContinuationUri", body?.continue.uri) +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Quote.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Quote.bru new file mode 100644 index 0000000000..40119c7943 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/Grant Request Quote.bru @@ -0,0 +1,46 @@ +meta { + name: Grant Request Quote + type: http + seq: 5 +} + +post { + url: {{senderTenantOpenPaymentsAuthHost}} + body: json + auth: none +} + +body:json { + { + "access_token": { + "access": [ + { + "type": "quote", + "actions": [ + "create", "read" + ] + } + ] + }, + "client": "{{clientWalletAddress}}" + } + +} + +script:pre-request { + const scripts = require('./scripts'); + + await scripts.addSignatureHeaders(); +} + +script:post-response { + const scripts = require('./scripts'); + + scripts.storeTokenDetails(); +} + +tests { + test("Status code is 200", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/folder.bru b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/folder.bru new file mode 100644 index 0000000000..e8f92a2937 --- /dev/null +++ b/bruno/collections/Rafiki/Examples/Tenanted Open Payments - Tenanted Environment Only/folder.bru @@ -0,0 +1,4 @@ +meta { + name: Tenanted Open Payments - Tenanted Environment Only + seq: 5 +} diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Continuation Request.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Continuation Request.bru index 01d39ebf3f..9859237b18 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Continuation Request.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Continuation Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/continue/{{continueId}} + url: {{senderOpenPaymentsContinuationUri}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru index 071e7df0be..d6d4ab3a27 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru @@ -36,7 +36,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); bru.setEnvVar("quoteDebitAmount", JSON.stringify({ diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru index d59f39b24f..b30f6db7a5 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru @@ -28,7 +28,7 @@ script:post-response { } const body = res.getBody() - + bru.setEnvVar("receiverAssetCode", body?.assetCode) bru.setEnvVar("receiverAssetScale", body?.assetScale) @@ -38,10 +38,23 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if ( + resourceUrl.hostname.includes('cloud-nine-wallet') || + resourceUrl.hostname.includes('happy-life-bank') + ){ + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? 3000 : 4000 + bru.setEnvVar("receiverOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("receiverOpenPaymentsHost", body?.resourceServer); + } + + } tests { diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru index 9665a40e32..f42f5d53bb 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru @@ -37,10 +37,18 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } + + const resourceUrl = url.parse(body?.resourceServer) + if (resourceUrl.hostname.includes('cloud-nine-wallet') || resourceUrl.hostname.includes('happy-life-bank')) { + const port = resourceUrl.hostname.includes('cloud-nine-wallet') ? bru.getEnvVar('senderOpenPaymentsPort') : bru.getEnvVar('receiverOpenPaymentsPort') + bru.setEnvVar("senderOpenPaymentsHost", 'http://localhost:' + port + resourceUrl.path); + } else { + bru.setEnvVar("senderOpenPaymentsHost", body?.resourceServer); + } } tests { diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru index 3a4e984bae..3c4b667956 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } @@ -46,6 +46,9 @@ script:post-response { const scripts = require('./scripts'); scripts.storeTokenDetails(); + + const body = res.getBody() + bru.setEnvVar("senderOpenPaymentsContinuationUri", body?.continue.uri) } tests { diff --git a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru index c718828e1c..8ed22f916a 100644 --- a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru @@ -42,7 +42,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); } diff --git a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru index 9fd7a87872..18a5fc0038 100644 --- a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru +++ b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru @@ -10,7 +10,7 @@ get { auth: none } -query { +params:query { first: 10 wallet-address: {{receiverWalletAddress}} ~cursor: ea3bf38f-2719-4473-a0f7-4ba967d1d81b diff --git a/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payments/Create Outgoing Payment.bru b/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payments/Create Outgoing Payment.bru index 62ec4793f7..0a315cd9d6 100644 --- a/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payments/Create Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Open Payments APIs/Outgoing Payments/Create Outgoing Payment.bru @@ -16,7 +16,7 @@ headers { body:json { { - "walletAddress": {{senderWalletAddress}}, + "walletAddress": "{{senderWalletAddress}}", "quoteId": "{{senderOpenPaymentsHost}}/quotes/{{quoteId}}", "metadata": { "description": "yolo", diff --git a/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru b/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru index 4739e2baa5..2545bd3b59 100644 --- a/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru +++ b/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru index d13c7f02cf..6a0426c85c 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Approve Incoming Payment.bru @@ -31,5 +31,5 @@ body:graphql:vars { script:pre-request { const scripts = require('./scripts'); - scripts.addApiSignatureHeader(); + scripts.addApiSignatureHeader('backend', 'receiver'); } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru index 775e508187..5568bc9635 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Incoming Payment.bru @@ -32,5 +32,5 @@ body:graphql:vars { script:pre-request { const scripts = require('./scripts'); - scripts.addApiSignatureHeader(); + scripts.addApiSignatureHeader('backend', 'receiver'); } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Outgoing Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Outgoing Payment.bru index c1585ada96..8511ac17d6 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Cancel Outgoing Payment.bru @@ -68,3 +68,9 @@ body:graphql:vars { } } } + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Quote.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Quote.bru index 9878183e41..8eb7adb841 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Quote.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Quote.bru @@ -58,11 +58,7 @@ script:pre-request { const randomInt = Math.floor(Math.random() * (1001)); const initialRequest = bru.getEnvVar("initialWalletAddressRequest"); - - const postRequest = { - method: 'post', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ + const postRequestBody = { query: initialRequest.body.query, variables: { "input": { @@ -71,7 +67,17 @@ script:pre-request { "publicName": "Simon" } } - }) + } + + const signature = scripts.generateBackendApiSignature(postRequestBody) + const postRequest = { + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'tenant-id': bru.getEnvVar('senderTenantId'), + 'signature': signature + }, + body: JSON.stringify(postRequestBody) }; const response = await fetch(`${initialRequest.url}`, postRequest); diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant Settings.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant Settings.bru new file mode 100644 index 0000000000..1cb3eef572 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant Settings.bru @@ -0,0 +1,46 @@ +meta { + name: Create Tenant Settings + type: graphql + seq: 58 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +headers { + tenant-id: 438fa74a-fa7d-4317-9ced-dde32ece1787 +} + +body:graphql { + mutation CreateTenantSettings($input: CreateTenantSettingsInput!) { + createTenantSettings(input:$input) { + settings { + key + value + } + } + } +} + +body:graphql:vars { + { + "input": { + "settings": [ + { + "key": "MY_KEY", + "value": "MY_VALUE" + } + ] + } + } + +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru new file mode 100644 index 0000000000..c078d15371 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru @@ -0,0 +1,50 @@ +meta { + name: Create Tenant + type: graphql + seq: 54 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input:$input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } +} + +body:graphql:vars { + { + "input": { + "email": "example@example.com", + "apiSecret": "test-secret", + "idpConsentUrl": "https://example.com/consent", + "idpSecret": "test-idp-secret" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.data) { + bru.setEnvVar("tenantId", body.data.createTenant.tenant?.id); + } +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address Withdrawal.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address Withdrawal.bru index 951fc73170..84fd86c253 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address Withdrawal.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address Withdrawal.bru @@ -18,7 +18,7 @@ body:graphql { id walletAddress { id - url + address asset { id code diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address.bru index 3918a11c8b..c93e99844e 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Wallet Address.bru @@ -17,7 +17,7 @@ body:graphql { id createdAt publicName - url + address status asset { code @@ -40,7 +40,7 @@ body:graphql:vars { { "input": { "assetId": "{{assetId}}", - "url": "https://{{senderOpenPaymentsHost}}/timon/{{randomId}}", + "address": "https://cloud-nine-wallet-backend/timon/{{randomId}}", "publicName": "Timon", "additionalProperties": [ {"key" : "iban", "value": "NL93 8601 1117 947", "visibleInOpenPayments": true}, diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru new file mode 100644 index 0000000000..6c664049f7 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru @@ -0,0 +1,31 @@ +meta { + name: Delete Tenant + type: graphql + seq: 56 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation DeleteTenant($id: String!) { + deleteTenant(id:$id) { + success + } + } +} + +body:graphql:vars { + { + "id": "{{tenantId}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru new file mode 100644 index 0000000000..bf63ff8e20 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru @@ -0,0 +1,48 @@ +meta { + name: Get Incoming Payment By Tenant + type: graphql + seq: 54 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetIncomingPayment($id: String!) { + incomingPayment(id: $id) { + id + walletAddressId + client + state + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + metadata + createdAt + } + } +} + +body:graphql:vars { + { + "id": "{{incomingPaymentId}}", + "tenantId": "{{tenantId}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Tenants.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Tenants.bru new file mode 100644 index 0000000000..8709de487d --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Tenants.bru @@ -0,0 +1,35 @@ +meta { + name: Get Tenants + type: graphql + seq: 57 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetTenants { + tenants { + edges { + cursor + node { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } + } + +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses Keys.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses Keys.bru index e8ed641928..eb34d397fd 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses Keys.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses Keys.bru @@ -18,7 +18,7 @@ body:graphql { node { id publicName - url + address walletAddressKeys { edges { cursor @@ -41,3 +41,9 @@ body:graphql { } } } + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses.bru index 462d4e81d5..318f38eaae 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Wallet Addresses.bru @@ -18,7 +18,7 @@ body:graphql { node { id publicName - url + address } } } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru new file mode 100644 index 0000000000..acf2af04ce --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru @@ -0,0 +1,42 @@ +meta { + name: Update Tenant + type: graphql + seq: 55 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input:$input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } +} + +body:graphql:vars { + { + "input": { + "id": "{{tenantId}}", + "email": "updated@example.com", + "idpConsentUrl": "https://example.com/consent-updated", + "idpSecret": "updated-test-idp-secret" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Update Wallet Address.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Wallet Address.bru index 3388bad8c2..117759d139 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Update Wallet Address.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Wallet Address.bru @@ -22,7 +22,7 @@ body:graphql { withdrawalThreshold createdAt } - url + address publicName createdAt status diff --git a/bruno/collections/Rafiki/environments/Local Playground.bru b/bruno/collections/Rafiki/environments/Local Playground.bru index 6cb1033ff6..b8bb2d3edc 100644 --- a/bruno/collections/Rafiki/environments/Local Playground.bru +++ b/bruno/collections/Rafiki/environments/Local Playground.bru @@ -30,4 +30,10 @@ vars { assetIdTigerBeetle: 1 assetCode: USD assetScale: 2 + senderTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 + RafikiGraphqlHostTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 + senderOpenPaymentsPort: 3000 + receiverOpenPaymentsPort: 4000 + receiverTenantId: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + senderTenantWalletAddress: http://localhost:3000/cloud-ten/accounts/fgranklin } diff --git a/bruno/collections/Rafiki/scripts.js b/bruno/collections/Rafiki/scripts.js index aeff1000a2..31dff704b4 100644 --- a/bruno/collections/Rafiki/scripts.js +++ b/bruno/collections/Rafiki/scripts.js @@ -106,7 +106,7 @@ const scripts = { return `t=${timestamp}, v${version}=${digest}` }, - addApiSignatureHeader: function (packageName) { + addApiSignatureHeader: function (packageName, instance) { const body = this.sanitizeBody() const { variables } = body const formattedBody = { @@ -127,6 +127,16 @@ const scripts = { signature = this.generateBackendApiSignature(formattedBody) } req.setHeader('signature', signature) + switch (instance) { + case 'sender': + req.setHeader('tenant-id', bru.getEnvVar('senderTenantId')) + break + case 'receiver': + req.setHeader('tenant-id', bru.getEnvVar('receiverTenantId')) + break + default: + req.setHeader('tenant-id', bru.getEnvVar('senderTenantId')) + } }, addHostHeader: function (hostVarName) { @@ -181,7 +191,8 @@ const scripts = { method: 'post', headers: { signature: this.generateBackendApiSignature(postBody), - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'tenant-id': bru.getEnvVar('senderTenantId') }, body: JSON.stringify(postBody) } diff --git a/localenv/README.md b/localenv/README.md index 9677d391b5..eab4f5df36 100644 --- a/localenv/README.md +++ b/localenv/README.md @@ -189,6 +189,14 @@ Authentication is disabled by default for ease of development, but it can be ena pnpm localenv:compose:adminauth up ``` +The Admin UI requires a valid API secret and tenant id to make requests to the Admin APIs, which must be submitted via a form on the frontend. For our convenience, we log a link on Mock Account Servicing Entity (MASE) start that can be used to access the Admin UI and set the credentials automatically. The credentials used pull from the MASE’s `SIGNATURE_SECRET` and `OPERATOR_TENANT_ID` environment variables. + +``` +cloud-nine-mock-ase-1 | Local Dev Setup: +cloud-nine-mock-ase-1 | Use this URL to access the frontend with operator tenant credentials: +cloud-nine-mock-ase-1 | http://localhost:3010/?tenantId=438fa74a-fa7d-4317-9ced-dde32ece1787&apiSecret=iyIgCprjb9uL8wFckR%2BpLEkJWMB7FJhgkvqhTQR%2F964%3D +``` + For additional details on using the Rafiki Admin application within the Local Playground, including enabling authentication and managing users, see the [Local Playground Rafiki Admin](https://rafiki.dev/integration/playground/overview/#rafiki-admin) documentation. # Reference diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 3fa672a106..271024d306 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -25,6 +25,8 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Cloud Nine Wallet DISPLAY_ICON: wallet-icon.svg + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + FRONTEND_PORT: 3010 volumes: - ../cloud-nine-wallet/seed.yml:/workspace/seed.yml - ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem @@ -65,10 +67,13 @@ services: TIGERBEETLE_REPLICA_ADDRESSES: ${TIGERBEETLE_REPLICA_ADDRESSES-''} AUTH_SERVER_GRANT_URL: ${CLOUD_NINE_AUTH_SERVER_DOMAIN:-http://cloud-nine-wallet-auth:3006} AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-auth:3007 + AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-auth:3003/graphql' + AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' + AUTH_SERVICE_API_URL: 'http://cloud-nine-wallet-auth:3011' ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet} STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= - OPEN_PAYMENTS_URL: ${CLOUD_NINE_OPEN_PAYMENTS_URL:-http://cloud-nine-wallet-backend} + OPEN_PAYMENTS_URL: ${CLOUD_NINE_OPEN_PAYMENTS_URL:-https://cloud-nine-wallet-backend} WEBHOOK_URL: http://cloud-nine-wallet/webhooks EXCHANGE_RATES_URL: http://cloud-nine-wallet/rates REDIS_URL: redis://shared-redis:6379/0 @@ -76,6 +81,7 @@ services: ILP_CONNECTOR_URL: ${CLOUD_NINE_CONNECTOR_URL:-http://cloud-nine-wallet-backend:3002} ENABLE_TELEMETRY: true KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0 + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 depends_on: - shared-database - shared-redis @@ -105,6 +111,7 @@ services: - '3006:3006' - "9230:9229" - '3009:3009' + - '3011:3011' environment: NODE_ENV: ${NODE_ENV:-development} TRUST_PROXY: ${TRUST_PROXY} @@ -115,6 +122,8 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + SERVICE_API_PORT: 3011 depends_on: - shared-database - shared-redis @@ -159,9 +168,8 @@ services: GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql OPEN_PAYMENTS_URL: https://cloud-nine-wallet-backend/ ENABLE_INSECURE_MESSAGE_COOKIE: true - AUTH_ENABLED: false + AUTH_ENABLED: false SIGNATURE_VERSION: 1 - SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-backend diff --git a/localenv/cloud-nine-wallet/seed.yml b/localenv/cloud-nine-wallet/seed.yml index 75ad0398be..e3f7beadc6 100644 --- a/localenv/cloud-nine-wallet/seed.yml +++ b/localenv/cloud-nine-wallet/seed.yml @@ -21,6 +21,10 @@ peers: peerUrl: http://happy-life-bank-backend:3002 peerIlpAddress: test.happy-life-bank liquidityThreshold: 1000000 + tokens: + incoming: + - test-USD-happy-life-bank-cloud-nine-wallet + outgoing: test-USD-cloud-nine-wallet-happy-life-bank accounts: - name: 'Grace Franklin' path: accounts/gfranklin diff --git a/localenv/cloud-ten-wallet/docker-compose.yml b/localenv/cloud-ten-wallet/docker-compose.yml new file mode 100644 index 0000000000..9b732a6deb --- /dev/null +++ b/localenv/cloud-ten-wallet/docker-compose.yml @@ -0,0 +1,39 @@ +name: c10 +services: + cloud-ten-mock-ase: + hostname: cloud-ten-wallet + image: rafiki-mock-ase + build: + context: ../.. + dockerfile: ./localenv/mock-account-servicing-entity/Dockerfile + restart: always + networks: + - rafiki + ports: + - '5030:80' + environment: + LOG_LEVEL: debug + PORT: 80 + SEED_FILE_LOCATION: /workspace/seed.yml + KEY_FILE: /workspace/private-key.pem + OPEN_PAYMENTS_URL: ${CLOUD_NINE_OPEN_PAYMENTS_URL:-https://cloud-nine-wallet-backend} + AUTH_SERVER_DOMAIN: ${CLOUD_NINE_AUTH_SERVER_DOMAIN:-http://localhost:3006} + TESTNET_AUTOPEER_URL: ${TESTNET_AUTOPEER_URL} + GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + IDP_SECRET: ue3ixgIiWLIlWOd4w5KO78scYpFH+vHuCJ33lnjgzEg= + DISPLAY_NAME: Cloud Ten Wallet + DISPLAY_ICON: wallet-icon.svg + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + FRONTEND_PORT: 3010 + IS_TENANT: true + volumes: + - ../cloud-ten-wallet/seed.yml:/workspace/seed.yml + - ../cloud-ten-wallet/private-key.pem:/workspace/private-key.pem + depends_on: + cloud-nine-backend: + condition: service_healthy + cloud-nine-mock-ase: + condition: service_started + \ No newline at end of file diff --git a/localenv/cloud-ten-wallet/private-key.pem b/localenv/cloud-ten-wallet/private-key.pem new file mode 100644 index 0000000000..5814233c8c --- /dev/null +++ b/localenv/cloud-ten-wallet/private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxfM9mUurUGnwlMMQEDclDEQnX7c49BoGKOB48URBxO +-----END PRIVATE KEY----- diff --git a/localenv/cloud-ten-wallet/seed.yml b/localenv/cloud-ten-wallet/seed.yml new file mode 100644 index 0000000000..7cf65b5502 --- /dev/null +++ b/localenv/cloud-ten-wallet/seed.yml @@ -0,0 +1,100 @@ +assets: + - code: USD + scale: 2 + liquidity: 100000000 + liquidityThreshold: 10000000 + - code: EUR + scale: 2 + liquidity: 100000000 + liquidityThreshold: 10000000 + - code: MXN + scale: 2 + liquidity: 100000000 + liquidityThreshold: 10000000 + - code: JPY + scale: 0 + liquidity: 1000000 + liquidityThreshold: 100000 +peeringAsset: 'USD' +peers: + - initialLiquidity: '10000000' + peerUrl: http://happy-life-bank-backend:3002 + peerIlpAddress: test.happy-life-bank + liquidityThreshold: 1000000 + tokens: + incoming: + - test-USD-happy-life-bank-cloud-ten-wallet + outgoing: test-USD-cloud-ten-wallet-happy-life-bank +accounts: + - name: 'Frace Granklin' + path: accounts/fgranklin + id: 374e68c8-06d8-4b4f-b38a-9f047db870c5 + initialBalance: 40000000 + brunoEnvVar: fgranklinWalletAddress + assetCode: USD + - name: 'Hert Bamchest' + id: 9a6bbce5-23ab-4372-a7a7-45662e60a973 + initialBalance: 40000000 + path: accounts/hbamchest + brunoEnvVar: hbamchestWalletAddress + assetCode: USD + - name: "Galaxy's Best Croissant Co" + id: a7c4a79b-c66e-4901-9a28-c812fefbae1e + initialBalance: 20000000 + path: accounts/gbcc + brunoEnvVar: gbccWalletAddress + assetCode: USD + - name: "Penniless Account" + id: 66daacec-ee36-4d21-bca9-c8013ea5f8a7 + initialBalance: 50 + path: accounts/penniless + brunoEnvVar: pennilessWalletAddress + assetCode: USD + - name: "Ruca Lossi" + id: 53e03ae8-afca-483b-a02b-1db0d49411dc + initialBalance: 50 + path: accounts/rlossi + brunoEnvVar: rlossiWalletAddressId + assetCode: EUR +rates: + EUR: + MXN: 18.78 + USD: 1.10 + JPY: 157.83 + USD: + MXN: 17.07 + EUR: 0.91 + JPY: 147.71 + MXN: + USD: 0.059 + EUR: 0.054 + JPY: 8.65 + JPY: + USD: 0.007 + EUR: 0.006 + MXN: 0.12 +fees: + - fixed: 100 + basisPoints: 200 + asset: USD + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: EUR + scale: 2 + - fixed: 100 + basisPoints: 200 + asset: MXN + scale: 2 + - fixed: 1 + basisPoints: 200 + asset: JPY + scale: 0 +tenants: + - publicName: 'Cloud Ten Wallet' + apiSecret: 'LuagA4lRWSi9GRx5vBNr0vQHKrroaxkvg6ZMFjfLxPw=' + idpConsentUrl: 'http://localhost:5030/mock-idp' + idpSecret: 'ue3ixgIiWLIlWOd4w5KO78scYpFH+vHuCJ33lnjgzEg=' + walletAddressPrefix: 'https://cloud-nine-wallet-backend/cloud-ten' + webhookUrl: 'http://cloud-ten-wallet/webhooks' + id: 'bc293b79-8609-47bd-b914-6438b470aff8' diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 0ca6b5a86e..88335d627a 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -21,6 +21,8 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Happy Life Bank DISPLAY_ICON: bank-icon.svg + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + FRONTEND_PORT: 4010 volumes: - ../happy-life-bank/seed.yml:/workspace/seed.yml - ../happy-life-bank/private-key.pem:/workspace/private-key.pem @@ -58,17 +60,21 @@ services: USE_TIGERBEETLE: false AUTH_SERVER_GRANT_URL: ${HAPPY_LIFE_BANK_AUTH_SERVER_DOMAIN:-http://happy-life-bank-auth:3006} AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-auth:3007 + AUTH_ADMIN_API_URL: 'http://happy-life-bank-auth:4003/graphql' + AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' + AUTH_SERVICE_API_URL: 'http://happy-life-bank-auth:4011' ILP_ADDRESS: test.happy-life-bank ILP_CONNECTOR_URL: http://happy-life-bank-backend:4002 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= WEBHOOK_URL: http://happy-life-bank/webhooks - OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-http://happy-life-bank-backend} EXCHANGE_RATES_URL: http://happy-life-bank/rates + OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-https://happy-life-bank-backend} REDIS_URL: redis://shared-redis:6379/2 WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay} ENABLE_TELEMETRY: true KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d depends_on: - cloud-nine-backend healthcheck: @@ -95,6 +101,7 @@ services: - '4006:3006' - '9232:9229' - '4009:3009' + - '4011:4011' environment: NODE_ENV: development AUTH_DATABASE_URL: postgresql://happy_life_bank_auth:happy_life_bank_auth@shared-database/happy_life_bank_auth @@ -104,6 +111,8 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + SERVICE_API_PORT: 4011 depends_on: - cloud-nine-auth happy-life-admin: @@ -129,7 +138,6 @@ services: ENABLE_INSECURE_MESSAGE_COOKIE: true AUTH_ENABLED: false SIGNATURE_VERSION: 1 - SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-admin - happy-life-backend diff --git a/localenv/happy-life-bank/seed.yml b/localenv/happy-life-bank/seed.yml index 1a8e57cff7..3eb4824dc9 100644 --- a/localenv/happy-life-bank/seed.yml +++ b/localenv/happy-life-bank/seed.yml @@ -21,6 +21,18 @@ peers: peerUrl: http://cloud-nine-wallet-backend:3002 peerIlpAddress: test.cloud-nine-wallet liquidityThreshold: 1000000 + tokens: + incoming: + - test-USD-cloud-nine-wallet-happy-life-bank + outgoing: test-USD-happy-life-bank-cloud-nine-wallet + - initialLiquidity: '1000000000000' + peerUrl: http://cloud-nine-wallet-backend:3002 + peerIlpAddress: test.cloud-nine-wallet.bc293b79-8609-47bd-b914-6438b470aff8 + liquidityThreshold: 1000000 + tokens: + incoming: + - test-USD-cloud-ten-wallet-happy-life-bank + outgoing: test-USD-happy-life-bank-cloud-ten-wallet accounts: - name: 'Philip Fry' path: accounts/pfry diff --git a/localenv/mock-account-servicing-entity/app/entry.server.tsx b/localenv/mock-account-servicing-entity/app/entry.server.tsx index 0183875b14..5d0a1d656c 100644 --- a/localenv/mock-account-servicing-entity/app/entry.server.tsx +++ b/localenv/mock-account-servicing-entity/app/entry.server.tsx @@ -5,7 +5,7 @@ import { RemixServer } from '@remix-run/react' import { renderToPipeableStream } from 'react-dom/server' import { setupFromSeed } from 'mock-account-service-lib' import { CONFIG } from './lib/parse_config.server' -import { apolloClient } from './lib/apolloClient' +import { generateApolloClient } from './lib/apolloClient' import { mockAccounts } from './lib/accounts.server' declare global { @@ -14,8 +14,11 @@ declare global { } // Used for running seeds in a try loop with exponential backoff -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function callWithRetry(fn: () => any, depth = 0): Promise { +async function callWithRetry( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn: () => any, + depth = 0 +): Promise> { const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) try { @@ -30,15 +33,39 @@ async function callWithRetry(fn: () => any, depth = 0): Promise { } if (!global.__seeded) { - callWithRetry(async () => { - console.log('setting up from seed...') - return setupFromSeed(CONFIG, apolloClient, mockAccounts, { - logLevel: 'debug', - pinoPretty: true - }) - }) - .then(() => { + const tenantId = process.env.OPERATOR_TENANT_ID + const apiSecret = process.env.SIGNATURE_SECRET + + if (!tenantId || !apiSecret) { + throw new Error( + 'Must set OPERATOR_TENANT_ID and SIGNATURE_SECRET environment variables' + ) + } + + callWithRetry( + async (): Promise<{ tenantId: string; apiSecret: string } | undefined> => { + console.log('setting up from seed...') + return setupFromSeed(CONFIG, generateApolloClient, mockAccounts, { + logLevel: 'debug', + pinoPretty: true + }) + } + ) + .then((seedResult: { tenantId: string; apiSecret: string } | undefined) => { global.__seeded = true + setTimeout(() => { + const url = new URL(`http://localhost:${process.env.FRONTEND_PORT}/`) + const params = new URLSearchParams({ + tenantId: seedResult?.tenantId ?? tenantId, + apiSecret: seedResult?.apiSecret ?? apiSecret + }) + + url.search = params.toString() + + console.log( + `Local Dev Setup:\nUse this URL to access the frontend with ${process.env.IS_TENANT ? '' : 'operator '} tenant credentials:\n${url}\n` + ) + }, 2000) }) .catch((e) => { console.log( diff --git a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts index 505aca43b9..fbd6912076 100644 --- a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts +++ b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts @@ -48,32 +48,48 @@ const errorLink = onError(({ graphQLErrors }) => { } }) -const authLink = setContext((request, { headers }) => { - if (!process.env.SIGNATURE_SECRET || !process.env.SIGNATURE_VERSION) - return { headers } - const timestamp = Date.now() - const version = process.env.SIGNATURE_VERSION +interface TenantOptions { + tenantId: string + apiSecret: string +} - const { query, variables, operationName } = request - const formattedRequest = { - variables, - operationName, - query: print(query) - } +const createAuthLink = (options?: TenantOptions) => { + return setContext((request, { headers }) => { + if ( + !(options || process.env.SIGNATURE_SECRET) || + !process.env.SIGNATURE_VERSION + ) + return { headers } + const timestamp = Date.now() + const version = process.env.SIGNATURE_VERSION - const payload = `${timestamp}.${canonicalize(formattedRequest)}` - const hmac = createHmac('sha256', process.env.SIGNATURE_SECRET) - hmac.update(payload) - const digest = hmac.digest('hex') - return { - headers: { - ...headers, - signature: `t=${timestamp}, v${version}=${digest}` + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) } - } -}) -const link = ApolloLink.from([errorLink, authLink, httpLink]) + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac( + 'sha256', + options ? options.apiSecret : (process.env.SIGNATURE_SECRET as string) + ) + hmac.update(payload) + const digest = hmac.digest('hex') + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}`, + ['tenant-id']: options + ? options.tenantId + : process.env.OPERATOR_TENANT_ID + } + } + }) +} + +const link = ApolloLink.from([errorLink, createAuthLink(), httpLink]) export const apolloClient: ApolloClient = new ApolloClient({ @@ -91,3 +107,23 @@ export const apolloClient: ApolloClient = } } }) + +export function generateApolloClient( + options?: TenantOptions +): ApolloClient { + return new ApolloClient({ + cache: new InMemoryCache({}), + link: ApolloLink.from([createAuthLink(options), httpLink]), + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) +} diff --git a/localenv/mock-account-servicing-entity/app/lib/asset.server.ts b/localenv/mock-account-servicing-entity/app/lib/asset.server.ts index 262439dfcd..0f97f636ea 100644 --- a/localenv/mock-account-servicing-entity/app/lib/asset.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/asset.server.ts @@ -1,23 +1,35 @@ import { gql } from '@apollo/client' -import { apolloClient } from './apolloClient' +import { generateApolloClient } from './apolloClient' import type { QueryAssetsArgs } from 'generated/graphql' +import { TenantOptions } from './types' -export const listAssets = async (args: QueryAssetsArgs) => { - const response = await apolloClient.query({ +export const listAssets = async ( + args: QueryAssetsArgs, + tenantOptions?: TenantOptions +) => { + const response = await generateApolloClient(tenantOptions).query({ query: gql` query ListAssetsQuery( $after: String $before: String $first: Int $last: Int + $tenantId: String ) { - assets(after: $after, before: $before, first: $first, last: $last) { + assets( + after: $after + before: $before + first: $first + last: $last + tenantId: $tenantId + ) { edges { node { code id scale withdrawalThreshold + tenantId createdAt } } @@ -36,13 +48,14 @@ export const listAssets = async (args: QueryAssetsArgs) => { return response.data.assets } -export const loadAssets = async () => { +export const loadAssets = async (tenantOptions?: TenantOptions) => { let assets: { node: { code: string id: string scale: number withdrawalThreshold?: bigint | null + tenantId: string createdAt: string } }[] = [] @@ -50,7 +63,16 @@ export const loadAssets = async () => { let after: string | undefined while (hasNextPage) { - const response = await listAssets({ first: 100, after }) + const response = await listAssets( + { + first: 100, + after, + tenantId: tenantOptions?.tenantId + ? tenantOptions.tenantId + : process.env.OPERATOR_TENANT_ID + }, + tenantOptions + ) if (response.edges) { assets = [...assets, ...response.edges] diff --git a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts index e4a934def3..64bb6fdd34 100644 --- a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts @@ -15,6 +15,10 @@ if (!process.env.IDP_SECRET) { throw new Error('Environment variable IDP_SECRET is required') } +if (!process.env.OPERATOR_TENANT_ID) { + throw new Error('Environment variable OPERATOR_TENANT_ID is required') +} + export const CONFIG: Config = { seed: parse( readFileSync( @@ -26,5 +30,7 @@ export const CONFIG: Config = { testnetAutoPeerUrl: process.env.TESTNET_AUTOPEER_URL ?? '', authServerDomain: process.env.AUTH_SERVER_DOMAIN || 'http://localhost:3006', graphqlUrl: process.env.GRAPHQL_URL, - idpSecret: process.env.IDP_SECRET + idpSecret: process.env.IDP_SECRET, + operatorTenantId: process.env.OPERATOR_TENANT_ID, + isTenant: process.env.IS_TENANT === 'true' } diff --git a/localenv/mock-account-servicing-entity/app/lib/requesters.ts b/localenv/mock-account-servicing-entity/app/lib/requesters.ts index 14654de0a3..80d320bd57 100644 --- a/localenv/mock-account-servicing-entity/app/lib/requesters.ts +++ b/localenv/mock-account-servicing-entity/app/lib/requesters.ts @@ -7,13 +7,36 @@ import type { CreateWalletAddressKeyMutationResponse, CreateWalletAddressKeyInput } from 'generated/graphql' -import { apolloClient } from './apolloClient' +import { apolloClient, generateApolloClient } from './apolloClient' import { v4 as uuid } from 'uuid' +import { TenantOptions } from './types' + +export async function listTenants() { + const listTenantsQuery = gql` + query ListTenantsQuery { + tenants { + edges { + node { + id + apiSecret + } + } + } + } + ` + + const response = await apolloClient.query({ + query: listTenantsQuery + }) + + return response.data.tenants +} export async function depositPeerLiquidity( peerId: string, amount: string, - transferUid: string + transferUid: string, + options?: TenantOptions ): Promise { const depositPeerLiquidityMutation = gql` mutation DepositPeerLiquidity($input: DepositPeerLiquidityInput!) { @@ -30,7 +53,7 @@ export async function depositPeerLiquidity( idempotencyKey: uuid() } } - return apolloClient + return generateApolloClient(options) .mutate({ mutation: depositPeerLiquidityMutation, variables: depositPeerLiquidityInput @@ -47,7 +70,8 @@ export async function depositPeerLiquidity( export async function depositAssetLiquidity( assetId: string, amount: number, - transferId: string + transferId: string, + options?: TenantOptions ): Promise { const depositAssetLiquidityMutation = gql` mutation DepositAssetLiquidity($input: DepositAssetLiquidityInput!) { @@ -64,7 +88,7 @@ export async function depositAssetLiquidity( idempotencyKey: uuid() } } - return apolloClient + return generateApolloClient(options) .mutate({ mutation: depositAssetLiquidityMutation, variables: depositAssetLiquidityInput @@ -81,14 +105,15 @@ export async function depositAssetLiquidity( export async function createWalletAddress( accountName: string, accountUrl: string, - assetId: string + assetId: string, + options?: TenantOptions ): Promise { const createWalletAddressMutation = gql` mutation CreateWalletAddress($input: CreateWalletAddressInput!) { createWalletAddress(input: $input) { walletAddress { id - url + address publicName } } @@ -96,12 +121,12 @@ export async function createWalletAddress( ` const createWalletAddressInput: CreateWalletAddressInput = { assetId, - url: accountUrl, + address: accountUrl, publicName: accountName, additionalProperties: [] } - return apolloClient + return generateApolloClient(options) .mutate({ mutation: createWalletAddressMutation, variables: { @@ -121,10 +146,12 @@ export async function createWalletAddress( export async function createWalletAddressKey({ walletAddressId, - jwk + jwk, + options }: { walletAddressId: string jwk: string + options?: TenantOptions }): Promise { const createWalletAddressKeyMutation = gql` mutation CreateWalletAddressKey($input: CreateWalletAddressKeyInput!) { @@ -141,7 +168,7 @@ export async function createWalletAddressKey({ jwk: jwk as unknown as JwkInput } - return apolloClient + return generateApolloClient(options) .mutate({ mutation: createWalletAddressKeyMutation, variables: { @@ -157,7 +184,8 @@ export async function createWalletAddressKey({ } export async function getWalletAddressPayments( - walletAddressId: string + walletAddressId: string, + options?: TenantOptions ): Promise { const query = gql` query WalletAddress($id: String!) { @@ -225,7 +253,7 @@ export async function getWalletAddressPayments( } } ` - return apolloClient + return generateApolloClient(options) .query({ query, variables: { diff --git a/localenv/mock-account-servicing-entity/app/lib/types.ts b/localenv/mock-account-servicing-entity/app/lib/types.ts index 9b981e2f56..5a8350be2a 100644 --- a/localenv/mock-account-servicing-entity/app/lib/types.ts +++ b/localenv/mock-account-servicing-entity/app/lib/types.ts @@ -1,3 +1,4 @@ +import { SeedInstance } from 'mock-account-service-lib' import type { z } from 'zod' export type AccessAction = 'create' | 'read' | 'list' | 'complete' @@ -33,6 +34,11 @@ export type InstanceConfig = { background: string } +export type TenantInstanceConfig = { + isTenant: boolean + seed: SeedInstance +} + export type JSONError = { errors: z.typeToFlattenedError> } @@ -43,3 +49,9 @@ type Keys = T extends any ? keyof T : never export type ZodFieldErrors = { [P in Keys>]?: string[] | undefined } + +export type TenantOptions = { + tenantId: string + apiSecret: string + walletAddressPrefix?: string +} diff --git a/localenv/mock-account-servicing-entity/app/lib/utils.ts b/localenv/mock-account-servicing-entity/app/lib/utils.ts index 0efa2ec40f..086eba943a 100644 --- a/localenv/mock-account-servicing-entity/app/lib/utils.ts +++ b/localenv/mock-account-servicing-entity/app/lib/utils.ts @@ -1,3 +1,10 @@ +import { Session } from '@remix-run/node' +import { TenantInstanceConfig, TenantOptions } from './types' +import { parse } from 'yaml' +import { readFileSync } from 'fs' +import { listTenants } from './requesters' +import { TenantEdge } from 'generated/graphql' + export function formatAmount(amount: string, scale: number) { const value = BigInt(amount) const divisor = BigInt(10 ** scale) @@ -25,3 +32,44 @@ export function getOpenPaymentsUrl() { return env.OPEN_PAYMENTS_URL } + +export async function getTenantCredentials( + session: Session +): Promise { + const instanceConfig: TenantInstanceConfig = { + isTenant: process?.env?.IS_TENANT === 'true', + seed: parse( + readFileSync( + process?.env?.SEED_FILE_LOCATION || './seed.example.yaml' + ).toString('utf-8') + ) + } + + if (!instanceConfig.isTenant) { + return + } + const tenantId = session.get('tenantId') + const apiSecret = session.get('apiSecret') + const walletAddressPrefix = session.get('walletAddressPrefix') + if (!tenantId || !apiSecret || !walletAddressPrefix) { + const tenants = await listTenants() + const tenant: TenantEdge = tenants.edges.find( + (tenant: TenantEdge) => + tenant.node.apiSecret === instanceConfig.seed.tenants[0].apiSecret + ) + + session.set('tenantId', tenant.node.id) + session.set('apiSecret', tenant.node.apiSecret) + session.set( + 'walletAddressPrefix', + instanceConfig.seed.tenants[0].walletAddressPrefix + ) + return { + tenantId: tenant.node.id, + apiSecret: tenant.node.apiSecret, + walletAddressPrefix: instanceConfig.seed.tenants[0].walletAddressPrefix + } + } else { + return { tenantId, apiSecret, walletAddressPrefix } + } +} diff --git a/localenv/mock-account-servicing-entity/app/lib/wallet.server.ts b/localenv/mock-account-servicing-entity/app/lib/wallet.server.ts index af3b1d7327..e83d552fe0 100644 --- a/localenv/mock-account-servicing-entity/app/lib/wallet.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/wallet.server.ts @@ -2,6 +2,7 @@ import { generateJwk, generateKey } from '@interledger/http-signature-utils' import { mockAccounts } from './accounts.server' import { createWalletAddressKey, createWalletAddress } from './requesters' import { getOpenPaymentsUrl } from './utils' +import { TenantOptions } from './types' export type CreateWalletParams = { path: string @@ -10,22 +11,21 @@ export type CreateWalletParams = { accountId: string } -export async function createWallet({ - name, - path, - assetId, - accountId -}: CreateWalletParams): Promise { +export async function createWallet( + { name, path, assetId, accountId }: CreateWalletParams, + options?: TenantOptions +): Promise { const walletAddress = await createWalletAddress( name, - `${getOpenPaymentsUrl()}/${path}`, - assetId + `${options?.walletAddressPrefix ?? getOpenPaymentsUrl()}/${path}`, + assetId, + options ) await mockAccounts.setWalletAddress( accountId, walletAddress.id, - walletAddress.url + walletAddress.address ) await createWalletAddressKey({ @@ -33,6 +33,7 @@ export async function createWallet({ jwk: generateJwk({ keyId: `keyid-${accountId}`, privateKey: generateKey() - }) as unknown as string + }) as unknown as string, + options }) } diff --git a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts index 72c44fd7fc..96f99ac5d5 100644 --- a/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/webhooks.server.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client' import type { LiquidityMutationResponse } from 'generated/graphql' import type { Amount } from './transactions.server' import { mockAccounts } from './accounts.server' -import { apolloClient } from './apolloClient' +import { generateApolloClient } from './apolloClient' import { v4 as uuid } from 'uuid' import { depositAssetLiquidity, @@ -10,6 +10,7 @@ import { createWalletAddress } from './requesters' import { Webhook, WebhookEventType } from 'mock-account-service-lib' +import { TenantOptions } from './types' export interface AmountJSON { value: string @@ -61,7 +62,10 @@ export async function handleOutgoingPaymentCompletedFailed(wh: Webhook) { return } -export async function handleOutgoingPaymentCreated(wh: Webhook) { +export async function handleOutgoingPaymentCreated( + wh: Webhook, + options?: TenantOptions +) { if (wh.type !== WebhookEventType.OutgoingPaymentCreated) { throw new Error('Invalid event type when handling outgoing payment webhook') } @@ -79,7 +83,7 @@ export async function handleOutgoingPaymentCreated(wh: Webhook) { try { await mockAccounts.pendingDebit(acc.id, amt.value) } catch (err) { - await apolloClient.mutate({ + await generateApolloClient(options).mutate({ mutation: gql` mutation CancelOutgoingPayment($input: CancelOutgoingPaymentInput!) { cancelOutgoingPayment(input: $input) { @@ -100,7 +104,7 @@ export async function handleOutgoingPaymentCreated(wh: Webhook) { } // notify rafiki - await apolloClient + await generateApolloClient(options) .mutate({ mutation: gql` mutation DepositOutgoingPaymentLiquidity( @@ -129,7 +133,10 @@ export async function handleOutgoingPaymentCreated(wh: Webhook) { return } -export async function handleIncomingPaymentCompletedExpired(wh: Webhook) { +export async function handleIncomingPaymentCompletedExpired( + wh: Webhook, + options?: TenantOptions +) { if ( wh.type !== WebhookEventType.IncomingPaymentCompleted && wh.type !== WebhookEventType.IncomingPaymentExpired @@ -149,7 +156,7 @@ export async function handleIncomingPaymentCompletedExpired(wh: Webhook) { await mockAccounts.credit(acc.id, amt.value, false) - await apolloClient + await generateApolloClient(options) .mutate({ mutation: gql` mutation CreateIncomingPaymentWithdrawal( @@ -179,7 +186,10 @@ export async function handleIncomingPaymentCompletedExpired(wh: Webhook) { return } -export async function handleWalletAddressWebMonetization(wh: Webhook) { +export async function handleWalletAddressWebMonetization( + wh: Webhook, + options?: TenantOptions +) { const walletAddressObj = wh.data.walletAddress as WalletAddressObject const walletAddressId = walletAddressObj.id @@ -196,7 +206,7 @@ export async function handleWalletAddressWebMonetization(wh: Webhook) { const withdrawalId = uuid() try { - await apolloClient.mutate({ + await generateApolloClient(options).mutate({ mutation: gql` mutation CreateWalletAddressWithdrawal( $input: CreateWalletAddressWithdrawalInput! @@ -231,7 +241,7 @@ export async function handleWalletAddressWebMonetization(wh: Webhook) { const amount = parseAmount(walletAddressObj.receivedAmount as AmountJSON) await mockAccounts.credit(account.id, amount.value, true) - return await apolloClient.mutate({ + return await generateApolloClient(options).mutate({ mutation: gql` mutation PostLiquidityWithdrawal( $input: PostLiquidityWithdrawalInput! @@ -277,7 +287,7 @@ export async function handleWalletAddressNotFound(wh: Webhook) { await mockAccounts.setWalletAddress( account.id, walletAddress.id, - walletAddress.url + walletAddress.address ) } diff --git a/localenv/mock-account-servicing-entity/app/routes/_index.tsx b/localenv/mock-account-servicing-entity/app/routes/_index.tsx index 813129b852..59ce4c387b 100644 --- a/localenv/mock-account-servicing-entity/app/routes/_index.tsx +++ b/localenv/mock-account-servicing-entity/app/routes/_index.tsx @@ -1,9 +1,13 @@ -import { json } from '@remix-run/node' +import { json, LoaderFunctionArgs } from '@remix-run/node' import { useLoaderData, useNavigate } from '@remix-run/react' import { PageHeader, Button, Table } from '../components' import { getAccountsWithBalance } from '../lib/balances.server' +import { getTenantCredentials } from '~/lib/utils' +import { messageStorage } from '~/lib/message.server' -export const loader = async () => { +export const loader = async ({ request }: LoaderFunctionArgs) => { + const session = await messageStorage.getSession(request.headers.get('cookie')) + await getTenantCredentials(session) const accountsWithBalance = await getAccountsWithBalance() return json({ accountsWithBalance }) diff --git a/localenv/mock-account-servicing-entity/app/routes/accounts.$accountId.tsx b/localenv/mock-account-servicing-entity/app/routes/accounts.$accountId.tsx index 672a0d6db2..cf74616d63 100644 --- a/localenv/mock-account-servicing-entity/app/routes/accounts.$accountId.tsx +++ b/localenv/mock-account-servicing-entity/app/routes/accounts.$accountId.tsx @@ -23,7 +23,7 @@ import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { getAccountTransactions } from '../lib/transactions.server' import { loadAssets } from '~/lib/asset.server' import { updateAccountSchema, addLiquiditySchema } from '~/lib/validate.server' -import { getOpenPaymentsUrl } from '~/lib/utils' +import { getOpenPaymentsUrl, getTenantCredentials } from '~/lib/utils' import { ZodFieldErrors } from '~/lib/types' import { getAccountWithBalance, @@ -40,6 +40,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const session = await messageStorage.getSession(request.headers.get('cookie')) + const options = await getTenantCredentials(session) const account = await getAccountWithBalance(accountId) if (!account?.id) { return setMessageAndRedirect({ @@ -53,7 +54,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const transactions = await getAccountTransactions(accountId) - const assets = await loadAssets() + const assets = await loadAssets(options) return json({ account, diff --git a/localenv/mock-account-servicing-entity/app/routes/accounts.create.tsx b/localenv/mock-account-servicing-entity/app/routes/accounts.create.tsx index 56e7252678..5eecaa1a32 100644 --- a/localenv/mock-account-servicing-entity/app/routes/accounts.create.tsx +++ b/localenv/mock-account-servicing-entity/app/routes/accounts.create.tsx @@ -12,16 +12,21 @@ import { createWallet } from '~/lib/wallet.server' import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { createAccountSchema } from '~/lib/validate.server' import type { ZodFieldErrors } from '~/lib/types' -import { getOpenPaymentsUrl } from '~/lib/utils' +import { getOpenPaymentsUrl, getTenantCredentials } from '~/lib/utils' export async function loader() { - const assets = await loadAssets() + const session = await messageStorage.getSession() + const options = await getTenantCredentials(session) + const assets = await loadAssets(options) - return json({ assets }) + return json({ + assets, + options + }) } export default function CreateAccountPage() { - const { assets } = useLoaderData() + const { assets, options } = useLoaderData() const response = useActionData() const { state } = useNavigation() const isSubmitting = state === 'submitting' @@ -56,7 +61,7 @@ export default function CreateAccountPage() { /> asset.node.id == result.data.assetId ) @@ -128,9 +136,7 @@ export async function action({ request }: ActionFunctionArgs) { return json({ errors }, { status: 400 }) } - await createWallet({ ...result.data, accountId }) - - const session = await messageStorage.getSession(request.headers.get('cookie')) + await createWallet({ ...result.data, accountId }, options) return setMessageAndRedirect({ session, diff --git a/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx b/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx index b445ef448d..1a7ba5fd32 100644 --- a/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx +++ b/localenv/mock-account-servicing-entity/app/routes/mock-idp._index.tsx @@ -37,7 +37,10 @@ export enum AmountType { } export function loader() { - return json({ defaultIdpSecret: CONFIG.idpSecret }) + return json({ + defaultIdpSecret: CONFIG.idpSecret, + isTenant: process.env.IS_TENANT === 'true' + }) } function ConsentScreenBody({ @@ -222,13 +225,14 @@ type ConsentScreenProps = { // In production, ensure that secrets are handled securely and are not exposed to the client-side code. export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) { + const { defaultIdpSecret, isTenant } = useLoaderData() const [ctx, setCtx] = useState({ ready: false, thirdPartyName: '', thirdPartyUri: '', interactId: 'demo-interact-id', nonce: 'demo-interact-nonce', - returnUrl: 'http://localhost:3030/mock-idp/consent?', + returnUrl: `http://localhost:${isTenant ? 5030 : 3030}/mock-idp/consent?`, //TODO returnUrl: 'http://localhost:3030/mock-idp/consent?interactid=demo-interact-id&nonce=demo-interact-nonce', accesses: null, outgoingPaymentAccess: null, @@ -240,7 +244,6 @@ export default function ConsentScreen({ idpSecretParam }: ConsentScreenProps) { const queryParams = new URLSearchParams(location.search) const instanceConfig: InstanceConfig = useOutletContext() - const { defaultIdpSecret } = useLoaderData() const idpSecret = idpSecretParam ? idpSecretParam : defaultIdpSecret useEffect(() => { diff --git a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts index fe96f3ac3b..2d180867d7 100644 --- a/localenv/mock-account-servicing-entity/app/routes/webhooks.ts +++ b/localenv/mock-account-servicing-entity/app/routes/webhooks.ts @@ -11,6 +11,8 @@ import { handleIncomingPaymentCompletedExpired } from '~/lib/webhooks.server' import { WebhookEventType, Webhook } from 'mock-account-service-lib' +import { getTenantCredentials } from '~/lib/utils' +import { messageStorage } from '~/lib/message.server' export function parseError(e: unknown): string { return e instanceof Error && e.stack ? e.stack : String(e) @@ -20,10 +22,13 @@ export async function action({ request }: ActionFunctionArgs) { const wh: Webhook = await request.json() console.log('received webhook: ', JSON.stringify(wh)) + const session = await messageStorage.getSession() + const tenantOptions = await getTenantCredentials(session) + try { switch (wh.type) { case WebhookEventType.OutgoingPaymentCreated: - await handleOutgoingPaymentCreated(wh) + await handleOutgoingPaymentCreated(wh, tenantOptions) break case WebhookEventType.OutgoingPaymentCompleted: case WebhookEventType.OutgoingPaymentFailed: @@ -33,10 +38,10 @@ export async function action({ request }: ActionFunctionArgs) { break case WebhookEventType.IncomingPaymentCompleted: case WebhookEventType.IncomingPaymentExpired: - await handleIncomingPaymentCompletedExpired(wh) + await handleIncomingPaymentCompletedExpired(wh, tenantOptions) break case WebhookEventType.WalletAddressWebMonetization: - await handleWalletAddressWebMonetization(wh) + await handleWalletAddressWebMonetization(wh, tenantOptions) break case WebhookEventType.WalletAddressNotFound: await handleWalletAddressNotFound(wh) diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 3c43fbcc4b..35e0959e56 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -364,17 +367,47 @@ export type CreateReceiverResponse = { receiver?: Maybe; }; +export type CreateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['input']; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. Must be compliant with uuid v4. Will be generated automatically if not provided. */ + id?: InputMaybe; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; + /** Initial settings for tenant. */ + settings?: InputMaybe>; +}; + +export type CreateTenantSettingsInput = { + /** List of a settings for a tenant. */ + settings: Array; +}; + +export type CreateTenantSettingsMutationResponse = { + __typename?: 'CreateTenantSettingsMutationResponse'; + /** New tenant settings. */ + settings: Array; +}; + export type CreateWalletAddressInput = { /** Additional properties associated with the wallet address. */ additionalProperties?: InputMaybe>; + /** Wallet address. This cannot be changed. */ + address: Scalars['String']['input']; /** Unique identifier of the asset associated with the wallet address. This cannot be changed. */ assetId: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; - /** Wallet address URL. This cannot be changed. */ - url: Scalars['String']['input']; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; }; export type CreateWalletAddressKeyInput = { @@ -440,6 +473,11 @@ export type DeletePeerMutationResponse = { success: Scalars['Boolean']['output']; }; +export type DeleteTenantMutationResponse = { + __typename?: 'DeleteTenantMutationResponse'; + success: Scalars['Boolean']['output']; +}; + export type DepositAssetLiquidityInput = { /** Amount of liquidity to deposit. */ amount: Scalars['UInt64']['input']; @@ -580,6 +618,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -721,6 +761,9 @@ export type Mutation = { createQuote: QuoteResponse; /** Create an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ createReceiver: CreateReceiverResponse; + /** As an operator, create a tenant. */ + createTenant: TenantMutationResponse; + createTenantSettings?: Maybe; /** Create a new wallet address. */ createWalletAddress: CreateWalletAddressMutationResponse; /** Add a public key to a wallet address that is used to verify Open Payments requests. */ @@ -731,6 +774,8 @@ export type Mutation = { deleteAsset: DeleteAssetMutationResponse; /** Delete a peer. */ deletePeer: DeletePeerMutationResponse; + /** Delete a tenant. */ + deleteTenant: DeleteTenantMutationResponse; /** Deposit asset liquidity. */ depositAssetLiquidity?: Maybe; /** @@ -756,6 +801,8 @@ export type Mutation = { updateIncomingPayment: IncomingPaymentResponse; /** Update an existing peer. */ updatePeer: UpdatePeerMutationResponse; + /** Update a tenant. */ + updateTenant: TenantMutationResponse; /** Update an existing wallet address. */ updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ @@ -843,6 +890,16 @@ export type MutationCreateReceiverArgs = { }; +export type MutationCreateTenantArgs = { + input: CreateTenantInput; +}; + + +export type MutationCreateTenantSettingsArgs = { + input: CreateTenantSettingsInput; +}; + + export type MutationCreateWalletAddressArgs = { input: CreateWalletAddressInput; }; @@ -868,6 +925,11 @@ export type MutationDeletePeerArgs = { }; +export type MutationDeleteTenantArgs = { + id: Scalars['String']['input']; +}; + + export type MutationDepositAssetLiquidityArgs = { input: DepositAssetLiquidityInput; }; @@ -923,6 +985,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationUpdateTenantArgs = { + input: UpdateTenantInput; +}; + + export type MutationUpdateWalletAddressArgs = { input: UpdateWalletAddressInput; }; @@ -967,6 +1034,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1097,6 +1166,8 @@ export type Peer = Model & { name?: Maybe; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; + /** Unique identifier of the tenant associated with the peer. */ + tenantId: Scalars['ID']['output']; }; export type PeerEdge = { @@ -1150,6 +1221,10 @@ export type Query = { quote?: Maybe; /** Retrieve an Open Payments incoming payment by receiver ID. The receiver's wallet address can be hosted on this server or a remote Open Payments resource server. */ receiver?: Maybe; + /** Retrieve a tenant of the instance. */ + tenant: Tenant; + /** As an operator, fetch a paginated list of tenants on the instance. */ + tenants: TenantsConnection; /** Fetch a wallet address by its ID. */ walletAddress?: Maybe; /** Get a wallet address by its url if it exists */ @@ -1158,6 +1233,8 @@ export type Query = { walletAddresses: WalletAddressesConnection; /** Fetch a paginated list of webhook events. */ webhookEvents: WebhookEventsConnection; + /** Determine if the requester has operator permissions */ + whoami: WhoamiResponse; }; @@ -1184,6 +1261,7 @@ export type QueryAssetsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1204,6 +1282,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1214,6 +1293,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1234,6 +1314,7 @@ export type QueryPeersArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1247,6 +1328,20 @@ export type QueryReceiverArgs = { }; +export type QueryTenantArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryTenantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + + export type QueryWalletAddressArgs = { id: Scalars['String']['input']; }; @@ -1263,6 +1358,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1273,6 +1369,7 @@ export type QueryWebhookEventsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; export type Quote = { @@ -1291,6 +1388,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1374,6 +1473,64 @@ export enum SortOrder { Desc = 'DESC' } +export type Tenant = Model & { + __typename?: 'Tenant'; + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['output']; + /** The date and time that this tenant was created. */ + createdAt: Scalars['String']['output']; + /** The date and time that this tenant was deleted. */ + deletedAt?: Maybe; + /** Contact email of the tenant owner. */ + email?: Maybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['output']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: Maybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: Maybe; + /** Public name for the tenant. */ + publicName?: Maybe; + /** List of settings for the tenant. */ + settings: Array; +}; + +export type TenantEdge = { + __typename?: 'TenantEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant node in the list. */ + node: Tenant; +}; + +export type TenantMutationResponse = { + __typename?: 'TenantMutationResponse'; + tenant: Tenant; +}; + +export type TenantSetting = { + __typename?: 'TenantSetting'; + /** Key for this setting. */ + key: Scalars['String']['output']; + /** Value of a setting for this key. */ + value: Scalars['String']['output']; +}; + +export type TenantSettingInput = { + /** Key for this setting. */ + key: Scalars['String']['input']; + /** Value of a setting for this key. */ + value: Scalars['String']['input']; +}; + +export type TenantsConnection = { + __typename?: 'TenantsConnection'; + /** A list of edges representing tenants and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + export enum TransferState { /** The accounting transfer is pending */ Pending = 'PENDING', @@ -1446,6 +1603,21 @@ export type UpdatePeerMutationResponse = { peer?: Maybe; }; +export type UpdateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret?: InputMaybe; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['input']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; +}; + export type UpdateWalletAddressInput = { /** Additional properties associated with this wallet address. */ additionalProperties?: InputMaybe>; @@ -1476,6 +1648,8 @@ export type WalletAddress = Model & { __typename?: 'WalletAddress'; /** Additional properties associated with the wallet address. */ additionalProperties?: Maybe>>; + /** Wallet Address. */ + address: Scalars['String']['output']; /** Asset of the wallet address. */ asset: Asset; /** The date and time when the wallet address was created. */ @@ -1494,8 +1668,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; - /** Wallet Address URL. */ - url: Scalars['String']['output']; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** List of keys associated with this wallet address */ walletAddressKeys?: Maybe; }; @@ -1613,6 +1787,8 @@ export type WebhookEvent = Model & { data: Scalars['JSONObject']['output']; /** Unique identifier of the webhook event. */ id: Scalars['ID']['output']; + /** Tenant of the webhook event. */ + tenantId: Scalars['ID']['output']; /** Type of webhook event. */ type: Scalars['String']['output']; }; @@ -1638,6 +1814,12 @@ export type WebhookEventsEdge = { node: WebhookEvent; }; +export type WhoamiResponse = { + __typename?: 'WhoamiResponse'; + id: Scalars['String']['output']; + isOperator: Scalars['Boolean']['output']; +}; + export type WithdrawEventLiquidityInput = { /** Unique identifier of the event to withdraw liquidity from. */ eventId: Scalars['String']['input']; @@ -1716,7 +1898,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1754,6 +1936,9 @@ export type ResolversTypes = { CreateQuoteInput: ResolverTypeWrapper>; CreateReceiverInput: ResolverTypeWrapper>; CreateReceiverResponse: ResolverTypeWrapper>; + CreateTenantInput: ResolverTypeWrapper>; + CreateTenantSettingsInput: ResolverTypeWrapper>; + CreateTenantSettingsMutationResponse: ResolverTypeWrapper>; CreateWalletAddressInput: ResolverTypeWrapper>; CreateWalletAddressKeyInput: ResolverTypeWrapper>; CreateWalletAddressKeyMutationResponse: ResolverTypeWrapper>; @@ -1764,6 +1949,7 @@ export type ResolversTypes = { DeleteAssetMutationResponse: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DeleteTenantMutationResponse: ResolverTypeWrapper>; DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; @@ -1823,6 +2009,12 @@ export type ResolversTypes = { SetFeeResponse: ResolverTypeWrapper>; SortOrder: ResolverTypeWrapper>; String: ResolverTypeWrapper>; + Tenant: ResolverTypeWrapper>; + TenantEdge: ResolverTypeWrapper>; + TenantMutationResponse: ResolverTypeWrapper>; + TenantSetting: ResolverTypeWrapper>; + TenantSettingInput: ResolverTypeWrapper>; + TenantsConnection: ResolverTypeWrapper>; TransferState: ResolverTypeWrapper>; TransferType: ResolverTypeWrapper>; TriggerWalletAddressEventsInput: ResolverTypeWrapper>; @@ -1833,6 +2025,7 @@ export type ResolversTypes = { UpdateIncomingPaymentInput: ResolverTypeWrapper>; UpdatePeerInput: ResolverTypeWrapper>; UpdatePeerMutationResponse: ResolverTypeWrapper>; + UpdateTenantInput: ResolverTypeWrapper>; UpdateWalletAddressInput: ResolverTypeWrapper>; UpdateWalletAddressMutationResponse: ResolverTypeWrapper>; VoidLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1849,6 +2042,7 @@ export type ResolversTypes = { WebhookEventFilter: ResolverTypeWrapper>; WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; + WhoamiResponse: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; }; @@ -1886,6 +2080,9 @@ export type ResolversParentTypes = { CreateQuoteInput: Partial; CreateReceiverInput: Partial; CreateReceiverResponse: Partial; + CreateTenantInput: Partial; + CreateTenantSettingsInput: Partial; + CreateTenantSettingsMutationResponse: Partial; CreateWalletAddressInput: Partial; CreateWalletAddressKeyInput: Partial; CreateWalletAddressKeyMutationResponse: Partial; @@ -1895,6 +2092,7 @@ export type ResolversParentTypes = { DeleteAssetMutationResponse: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DeleteTenantMutationResponse: Partial; DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; DepositOutgoingPaymentLiquidityInput: Partial; @@ -1947,6 +2145,12 @@ export type ResolversParentTypes = { SetFeeInput: Partial; SetFeeResponse: Partial; String: Partial; + Tenant: Partial; + TenantEdge: Partial; + TenantMutationResponse: Partial; + TenantSetting: Partial; + TenantSettingInput: Partial; + TenantsConnection: Partial; TriggerWalletAddressEventsInput: Partial; TriggerWalletAddressEventsMutationResponse: Partial; UInt8: Partial; @@ -1955,6 +2159,7 @@ export type ResolversParentTypes = { UpdateIncomingPaymentInput: Partial; UpdatePeerInput: Partial; UpdatePeerMutationResponse: Partial; + UpdateTenantInput: Partial; UpdateWalletAddressInput: Partial; UpdateWalletAddressMutationResponse: Partial; VoidLiquidityWithdrawalInput: Partial; @@ -1970,6 +2175,7 @@ export type ResolversParentTypes = { WebhookEventFilter: Partial; WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; + WhoamiResponse: Partial; WithdrawEventLiquidityInput: Partial; }; @@ -2021,6 +2227,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2071,6 +2278,11 @@ export type CreateReceiverResponseResolvers; }; +export type CreateTenantSettingsMutationResponseResolvers = { + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2091,6 +2303,11 @@ export type DeletePeerMutationResponseResolvers; }; +export type DeleteTenantMutationResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeeResolvers = { assetId?: Resolver; basisPoints?: Resolver; @@ -2134,6 +2351,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2174,7 +2392,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2195,11 +2413,14 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; createReceiver?: Resolver>; + createTenant?: Resolver>; + createTenantSettings?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddress?: Resolver>; createWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deleteAsset?: Resolver>; deletePeer?: Resolver>; + deleteTenant?: Resolver>; depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2211,6 +2432,7 @@ export type MutationResolvers>; updateIncomingPayment?: Resolver>; updatePeer?: Resolver>; + updateTenant?: Resolver>; updateWalletAddress?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2231,6 +2453,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2294,6 +2517,7 @@ export type PeerResolvers, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; staticIlpAddress?: Resolver; + tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2323,10 +2547,13 @@ export type QueryResolvers>; quote?: Resolver, ParentType, ContextType, RequireFields>; receiver?: Resolver, ParentType, ContextType, RequireFields>; + tenant?: Resolver>; + tenants?: Resolver>; walletAddress?: Resolver, ParentType, ContextType, RequireFields>; walletAddressByUrl?: Resolver, ParentType, ContextType, RequireFields>; walletAddresses?: Resolver>; webhookEvents?: Resolver>; + whoami?: Resolver; }; export type QuoteResolvers = { @@ -2337,6 +2564,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2380,6 +2608,42 @@ export type SetFeeResponseResolvers; }; +export type TenantResolvers = { + apiSecret?: Resolver; + createdAt?: Resolver; + deletedAt?: Resolver, ParentType, ContextType>; + email?: Resolver, ParentType, ContextType>; + id?: Resolver; + idpConsentUrl?: Resolver, ParentType, ContextType>; + idpSecret?: Resolver, ParentType, ContextType>; + publicName?: Resolver, ParentType, ContextType>; + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantMutationResponseResolvers = { + tenant?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingResolvers = { + key?: Resolver; + value?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type TriggerWalletAddressEventsMutationResponseResolvers = { count?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2405,6 +2669,7 @@ export type UpdateWalletAddressMutationResponseResolvers = { additionalProperties?: Resolver>>, ParentType, ContextType>; + address?: Resolver; asset?: Resolver; createdAt?: Resolver; id?: Resolver; @@ -2414,7 +2679,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; - url?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2468,6 +2733,7 @@ export type WebhookEventResolvers; data?: Resolver; id?: Resolver; + tenantId?: Resolver; type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2484,6 +2750,12 @@ export type WebhookEventsEdgeResolvers; }; +export type WhoamiResponseResolvers = { + id?: Resolver; + isOperator?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AccountingTransfer?: AccountingTransferResolvers; AccountingTransferConnection?: AccountingTransferConnectionResolvers; @@ -2499,10 +2771,12 @@ export type Resolvers = { CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; + CreateTenantSettingsMutationResponse?: CreateTenantSettingsMutationResponseResolvers; CreateWalletAddressKeyMutationResponse?: CreateWalletAddressKeyMutationResponseResolvers; CreateWalletAddressMutationResponse?: CreateWalletAddressMutationResponseResolvers; DeleteAssetMutationResponse?: DeleteAssetMutationResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; + DeleteTenantMutationResponse?: DeleteTenantMutationResponseResolvers; Fee?: FeeResolvers; FeeEdge?: FeeEdgeResolvers; FeesConnection?: FeesConnectionResolvers; @@ -2536,6 +2810,11 @@ export type Resolvers = { Receiver?: ReceiverResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; + Tenant?: TenantResolvers; + TenantEdge?: TenantEdgeResolvers; + TenantMutationResponse?: TenantMutationResponseResolvers; + TenantSetting?: TenantSettingResolvers; + TenantsConnection?: TenantsConnectionResolvers; TriggerWalletAddressEventsMutationResponse?: TriggerWalletAddressEventsMutationResponseResolvers; UInt8?: GraphQLScalarType; UInt64?: GraphQLScalarType; @@ -2552,5 +2831,6 @@ export type Resolvers = { WebhookEvent?: WebhookEventResolvers; WebhookEventsConnection?: WebhookEventsConnectionResolvers; WebhookEventsEdge?: WebhookEventsEdgeResolvers; + WhoamiResponse?: WhoamiResponseResolvers; }; diff --git a/package.json b/package.json index cb93e6f7ba..509abc2957 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build": "tsc --build", "localenv:compose:psql": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml", "localenv:compose": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", + "localenv:compose:multitenancy": "docker compose -f ./localenv/cloud-ten-wallet/docker-compose.yml -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:psql:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml", "localenv:compose:telemetry": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/tigerbeetle/docker-compose.yml -f ./localenv/telemetry/docker-compose.yml --env-file ./localenv/tigerbeetle/.env.tigerbeetle", "localenv:compose:adminauth": "docker compose -f ./localenv/cloud-nine-wallet/docker-compose.yml -f ./localenv/happy-life-bank/docker-compose.yml -f ./localenv/merged/docker-compose.yml -f ./localenv/admin-auth/docker-compose.yml", diff --git a/packages/auth/jest.env.js b/packages/auth/jest.env.js index 712a6a7a31..423f55578a 100644 --- a/packages/auth/jest.env.js +++ b/packages/auth/jest.env.js @@ -4,3 +4,4 @@ process.env.IDENTITY_SERVER_SECRET = '2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=' process.env.AUTH_SERVER_URL = 'http://localhost:3006' process.env.IDENTITY_SERVER_URL = 'http://localhost:3030/mock-idp/' +process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' diff --git a/packages/auth/jest.setup.js b/packages/auth/jest.setup.js index edbfb6f7ec..b232ee53ad 100644 --- a/packages/auth/jest.setup.js +++ b/packages/auth/jest.setup.js @@ -2,6 +2,7 @@ const { knex } = require('knex') // eslint-disable-next-line @typescript-eslint/no-var-requires const { GenericContainer, Wait } = require('testcontainers') +require('./jest.env') // set environment variables const POSTGRES_PORT = 5432 const REDIS_PORT = 6379 diff --git a/packages/auth/migrations/20241125233415_create_tenants_table.js b/packages/auth/migrations/20241125233415_create_tenants_table.js new file mode 100644 index 0000000000..4846a07ce9 --- /dev/null +++ b/packages/auth/migrations/20241125233415_create_tenants_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('tenants', function (table) { + table.uuid('id').notNullable().primary() + table.string('idpConsentUrl') + table.string('idpSecret') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/auth/migrations/20241205153036_seed_operator_tenant.js b/packages/auth/migrations/20241205153036_seed_operator_tenant.js new file mode 100644 index 0000000000..a7288e1ccf --- /dev/null +++ b/packages/auth/migrations/20241205153036_seed_operator_tenant.js @@ -0,0 +1,47 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const OPERATOR_TENANT_ID = process.env['OPERATOR_TENANT_ID'] +const IDENTITY_SERVER_URL = process.env['IDENTITY_SERVER_URL'] +const IDENTITY_SERVER_SECRET = process.env['IDENTITY_SERVER_SECRET'] + +exports.up = function (knex) { + if (!OPERATOR_TENANT_ID) { + throw new Error( + 'Could not seed operator tenant. Please configure OPERATOR_TENANT_ID environment variables' + ) + } + + const seed = { + id: OPERATOR_TENANT_ID + } + + if (IDENTITY_SERVER_URL) { + seed['idpConsentUrl'] = IDENTITY_SERVER_URL + } + + if (IDENTITY_SERVER_SECRET) { + seed['idpSecret'] = IDENTITY_SERVER_SECRET + } + + return knex.raw(` + INSERT INTO "tenants" (${Object.keys(seed) + .map((key) => `"${key}"`) + .join(', ')}) + VALUES (${Object.values(seed) + .map((key) => `'${key}'`) + .join(', ')}) + `) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.raw(` + TRUNCATE "tenants" + `) +} diff --git a/packages/auth/migrations/20241206232423_add_tenant_to_grant.js b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js new file mode 100644 index 0000000000..279e0eeff5 --- /dev/null +++ b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('grants', function (table) { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "grants" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('grants', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('grants', function (table) { + table.dropColumn('tenantId') + }) +} diff --git a/packages/auth/src/access/service.test.ts b/packages/auth/src/access/service.test.ts index b6909edc8b..34b2be82ac 100644 --- a/packages/auth/src/access/service.test.ts +++ b/packages/auth/src/access/service.test.ts @@ -1,7 +1,5 @@ -import { faker } from '@faker-js/faker' import nock from 'nock' import { Knex } from 'knex' -import { v4 } from 'uuid' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { Config } from '../config/app' @@ -9,12 +7,14 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { AccessService } from './service' -import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' +import { Grant } from '../grant/model' import { IncomingPaymentRequest, OutgoingPaymentRequest } from './types' import { AccessError } from './errors' -import { generateNonce, generateToken } from '../shared/utils' +import { generateBaseGrant } from '../tests/grant' import { AccessType, AccessAction } from '@interledger/open-payments' import { Access } from './model' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Service', (): void => { let deps: IocContract @@ -23,19 +23,11 @@ describe('Access Service', (): void => { let trx: Knex.Transaction let grant: Grant - const generateBaseGrant = () => ({ - state: GrantState.Pending, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) - }) - beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) }) beforeAll(async (): Promise => { diff --git a/packages/auth/src/access/utils.test.ts b/packages/auth/src/access/utils.test.ts index 351a535cf1..0da2531a18 100644 --- a/packages/auth/src/access/utils.test.ts +++ b/packages/auth/src/access/utils.test.ts @@ -17,6 +17,8 @@ import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { generateToken, generateNonce } from '../shared/utils' import { compareRequestAndGrantAccessItems } from './utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access utilities', (): void => { let deps: IocContract @@ -25,6 +27,7 @@ describe('Access utilities', (): void => { let identifier: string let grant: Grant let grantAccessItem: Access + let tenant: Tenant const receiver: string = 'https://wallet.com/alice/incoming-payments/12341234-1234-1234-1234-123412341234' @@ -36,6 +39,7 @@ describe('Access utilities', (): void => { beforeEach(async (): Promise => { identifier = `https://example.com/${v4()}` + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -44,7 +48,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) grantAccessItem = await Access.query(trx).insertAndFetch({ @@ -241,7 +246,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) const grantAccessItem = await Access.query(trx).insertAndFetch({ diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index 9876e56046..1a6e978de9 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -24,6 +24,8 @@ import { import { GrantService } from '../grant/service' import { AccessTokenService } from './service' import { GNAPErrorCode } from '../shared/gnapErrors' +import { generateTenant } from '../tests/tenant' +import { Tenant } from '../tenant/model' describe('Access Token Routes', (): void => { let deps: IocContract @@ -96,7 +98,11 @@ describe('Access Token Routes', (): void => { const method = 'POST' beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -370,7 +376,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_TOKEN @@ -409,7 +419,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index dab4ca8625..0bf20dcf37 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -20,6 +20,8 @@ import { AccessItem } from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Token Service', (): void => { let deps: IocContract @@ -63,8 +65,9 @@ describe('Access Token Service', (): void => { let grant: Grant beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grant.access = [ await Access.query(trx).insertAndFetch({ @@ -186,8 +189,9 @@ describe('Access Token Service', (): void => { }) test('Introspection only returns requested access', async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) const grantWithTwoAccesses = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grantWithTwoAccesses.access = [ await Access.query(trx).insertAndFetch({ @@ -247,11 +251,14 @@ describe('Access Token Service', (): void => { }) describe('Revoke', (): void => { + let tenant: Tenant let grant: Grant let token: AccessToken beforeEach(async (): Promise => { + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -352,8 +359,10 @@ describe('Access Token Service', (): void => { let token: AccessToken let originalTokenValue: string beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index 3e8bb9d0f7..f45e39c8c8 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -54,6 +54,7 @@ import { Redis } from 'ioredis' import { LoggingPlugin } from './graphql/plugin' import { gnapServerErrorMiddleware } from './shared/gnapErrors' import { verifyApiSignature } from './shared/utils' +import { TenantService } from './tenant/service' export interface AppContextData extends DefaultContext { logger: Logger @@ -102,6 +103,7 @@ export interface AppServices { grantRoutes: Promise interactionRoutes: Promise redis: Promise + tenantService: Promise } export type AppContainer = IocContract @@ -111,6 +113,7 @@ export class App { private interactionServer!: Server private introspectionServer!: Server private adminServer!: Server + private serviceAPIServer!: Server private logger!: Logger private config!: IAppConfig private databaseCleanupRules!: { @@ -265,7 +268,7 @@ export class App { /* Back-channel GNAP Routes */ // Grant Initiation router.post( - '/', + '/:tenantId', createValidatorMiddleware(openApi.authServerSpec, { path: '/', method: HttpMethod.POST @@ -454,6 +457,51 @@ export class App { this.interactionServer = koa.listen(port) } + public async startServiceAPIServer(port: number | string): Promise { + const koa = await this.createKoaServer() + + const router = new Router() + router.use(bodyParser()) + + const errorHandler = async (ctx: Koa.Context, next: Koa.Next) => { + try { + await next() + } catch (err) { + const logger = await ctx.container.use('logger') + logger.info( + { + method: ctx.method, + route: ctx.path, + headers: ctx.headers, + params: ctx.params, + requestBody: ctx.request.body, + err + }, + 'Service API Error' + ) + } + } + + koa.use(errorHandler) + + router.get('/healthz', (ctx: AppContext): void => { + ctx.status = 200 + }) + + const tenantRoutes = await this.container.use('tenantRoutes') + + router.get('/tenant/:id', tenantRoutes.get) + router.post('/tenant', tenantRoutes.create) + router.patch('/tenant/:id', tenantRoutes.update) + router.delete('/tenant/:id', tenantRoutes.delete) + + koa.use(cors()) + koa.use(router.middleware()) + koa.use(router.routes()) + + this.serviceAPIServer = koa.listen(port) + } + private async createKoaServer(): Promise> { const koa = new Koa({ proxy: this.config.trustProxy @@ -499,6 +547,9 @@ export class App { if (this.introspectionServer) { await this.stopServer(this.introspectionServer) } + if (this.serviceAPIServer) { + await this.stopServer(this.serviceAPIServer) + } } private async stopServer(server: Server): Promise { @@ -529,6 +580,10 @@ export class App { return this.getPort(this.introspectionServer) } + public getServiceAPIPort(): number { + return this.getPort(this.serviceAPIServer) + } + private getPort(server: Server): number { const address = server?.address() if (address && !(typeof address == 'string')) { diff --git a/packages/auth/src/config/app.ts b/packages/auth/src/config/app.ts index 9956785459..7298fc2b12 100644 --- a/packages/auth/src/config/app.ts +++ b/packages/auth/src/config/app.ts @@ -43,6 +43,7 @@ export const Config = { authPort: envInt('AUTH_PORT', 3006), interactionPort: envInt('INTERACTION_PORT', 3009), introspectionPort: envInt('INTROSPECTION_PORT', 3007), + serviceAPIPort: envInt('SERVICE_API_PORT', 3011), env: envString('NODE_ENV', 'development'), trustProxy: envBool('TRUST_PROXY', false), enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false), @@ -78,7 +79,8 @@ export const Config = { process.env.REDIS_TLS_CA_FILE_PATH, process.env.REDIS_TLS_KEY_FILE_PATH, process.env.REDIS_TLS_CERT_FILE_PATH - ) + ), + operatorTenantId: envString('OPERATOR_TENANT_ID') } function parseRedisTlsConfig( diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 54cfb39201..4fb8f7dce1 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -9,6 +9,7 @@ import { } from '@interledger/open-payments' import { AccessToken, toOpenPaymentsAccessToken } from '../accessToken/model' import { Interaction } from '../interaction/model' +import { Tenant } from '../tenant/model' export enum StartMethod { Redirect = 'redirect' @@ -61,6 +62,14 @@ export class Grant extends BaseModel { from: 'grants.id', to: 'interactions.grantId' } + }, + tenant: { + relation: Model.HasOneRelation, + modelClass: join(__dirname, '../tenant/model'), + join: { + from: 'grants.tenantId', + to: 'tenants.id' + } } }) public access!: Access[] @@ -79,6 +88,10 @@ export class Grant extends BaseModel { public lastContinuedAt!: Date + public tenantId!: string + + public tenant?: Tenant + public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.lastContinuedAt = new Date() @@ -192,3 +205,11 @@ export function isRevokedGrant(grant: Grant): boolean { grant.finalizationReason === GrantFinalization.Revoked ) } + +export interface GrantWithTenant extends Grant { + tenant: NonNullable +} + +export function isGrantWithTenant(grant: Grant): grant is GrantWithTenant { + return !!grant.tenant +} diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index ed19326ae8..496b6c5e7f 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -32,10 +32,17 @@ import { AccessTokenService } from '../accessToken/service' import { generateNonce } from '../shared/utils' import { ClientService } from '../client/service' import { withConfigOverride } from '../tests/helpers' -import { AccessAction, AccessType } from '@interledger/open-payments' +import { + AccessAction, + AccessType, + GrantContinuation, + PendingGrant +} from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' import { AccessError } from '../access/errors' export const TEST_CLIENT_DISPLAY = { @@ -72,6 +79,18 @@ const BASE_GRANT_REQUEST = { } } +function getGrantContinueId(continueUrl: string): string { + const continueUrlObj = new URL(continueUrl) + const pathItems = continueUrlObj.pathname.split('/') + return pathItems[pathItems.length - 1] +} + +function getInteractionId(redirectUrl: string): string { + const redirectUrlObj = new URL(redirectUrl) + const pathItems = redirectUrlObj.pathname.split('/') + return pathItems[pathItems.length - 2] +} + describe('Grant Routes', (): void => { let deps: IocContract let appContainer: TestContainer @@ -81,10 +100,14 @@ describe('Grant Routes', (): void => { let clientService: ClientService let interactionService: InteractionService + let tenant: Tenant let grant: Grant beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -174,7 +197,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -208,6 +233,14 @@ describe('Grant Routes', (): void => { ).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation) + .continue.access_token.value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: expect.any(String), @@ -217,9 +250,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/continue/${createdGrant.continueId}` } }) } @@ -253,7 +286,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -261,16 +296,37 @@ describe('Grant Routes', (): void => { await expect(grantRoutes.create(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as PendingGrant).continue.uri + ), + continueToken: (ctx.body as PendingGrant).continue.access_token.value + }) + assert.ok(createdGrant) + const createdInteraction = await Interaction.query().findOne({ + nonce: (ctx.body as PendingGrant).interact.finish, + id: getInteractionId((ctx.body as PendingGrant).interact.redirect) + }) + assert.ok(createdInteraction) + const expectedRedirectUrl = new URL( + config.authServerUrl + + `/interact/${createdInteraction.id}/${createdInteraction.nonce}` + ) + expectedRedirectUrl.searchParams.set( + 'clientName', + TEST_CLIENT_DISPLAY.name + ) + expectedRedirectUrl.searchParams.set('clientUri', CLIENT) expect(ctx.body).toEqual({ interact: { - redirect: expect.any(String), - finish: expect.any(String) + redirect: expectedRedirectUrl.toString(), + finish: createdInteraction.nonce }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String), + uri: `${config.authServerUrl}/continue/${createdGrant.continueId}`, wait: Config.waitTimeSeconds } }) @@ -278,6 +334,35 @@ describe('Grant Routes', (): void => { scope.done() }) + test('Does not create interactive grant if tenant has no idp', async (): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + { + tenantId: unconfiguredTenant.id + } + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 400, + code: GNAPErrorCode.InvalidRequest, + message: 'invalid tenant' + }) + }) + test('Does not create grant if token issuance fails', async (): Promise => { jest .spyOn(accessTokenService, 'create') @@ -292,7 +377,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -328,7 +415,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { ...BASE_GRANT_REQUEST, interact: undefined } @@ -365,7 +454,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -387,7 +478,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const grantRequest = { @@ -424,7 +517,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -457,7 +552,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { ...BASE_GRANT_REQUEST, @@ -495,6 +592,52 @@ describe('Grant Routes', (): void => { }) scope.done() }) + + test('Fails to initiate a grant without providing a tenant id', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + {} + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) + + test('Fails to initiate a grant if the provided tenant does not exist', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + { + tenantId: v4() + } + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) }) describe('/continue', (): void => { @@ -504,6 +647,7 @@ describe('Grant Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Approved }) ) @@ -537,7 +681,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -556,6 +701,14 @@ describe('Grant Routes', (): void => { assert.ok(accessToken) expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation).continue.access_token + .value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: accessToken.value, @@ -571,9 +724,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/continue/${createdGrant.continueId}` } }) }) @@ -588,7 +741,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) @@ -606,6 +760,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has not been granted', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Pending }) ) @@ -632,7 +787,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -650,6 +806,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has been revoked', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -672,7 +829,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -696,7 +854,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -720,7 +879,9 @@ describe('Grant Routes', (): void => { Authorization: `GNAP ${grant.continueToken}` } }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { @@ -744,7 +905,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -760,7 +922,9 @@ describe('Grant Routes', (): void => { }) test('Honors wait value when continuing too early', async (): Promise => { - const grantWithWait = await Grant.query().insert(generateBaseGrant()) + const grantWithWait = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -782,7 +946,8 @@ describe('Grant Routes', (): void => { } }, { - id: grantWithWait.continueId + id: grantWithWait.continueId, + tenantId: tenant.id } ) @@ -807,6 +972,7 @@ describe('Grant Routes', (): void => { async ({ state }): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state, noFinishMethod: true }) @@ -840,7 +1006,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -903,6 +1070,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a finalized grant', async (): Promise => { const finalizedPolledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, noFinishMethod: true }) @@ -946,7 +1114,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -964,6 +1133,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a grant faster than its wait method', async (): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }) ) @@ -990,7 +1160,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -1013,7 +1184,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -1024,6 +1196,7 @@ describe('Grant Routes', (): void => { test('Can revoke an existing grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -1037,7 +1210,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -1048,6 +1222,7 @@ describe('Grant Routes', (): void => { test('Cannot revoke an already revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -1061,7 +1236,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1081,7 +1257,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1109,7 +1286,8 @@ describe('Grant Routes', (): void => { : undefined }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject(error) diff --git a/packages/auth/src/grant/routes.ts b/packages/auth/src/grant/routes.ts index 647557dcc5..96f2099821 100644 --- a/packages/auth/src/grant/routes.ts +++ b/packages/auth/src/grant/routes.ts @@ -22,6 +22,8 @@ import { InteractionService } from '../interaction/service' import { canSkipInteraction } from './utils' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' +import { Tenant, isTenantWithIdp } from '../tenant/model' import { errorToGNAPCode, errorToHTTPCode, @@ -34,6 +36,7 @@ interface ServiceDependencies extends BaseService { accessTokenService: AccessTokenService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -77,6 +80,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): GrantRoutes { @@ -99,6 +103,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger: log, config } @@ -113,6 +118,16 @@ async function createGrant( deps: ServiceDependencies, ctx: CreateContext ): Promise { + const { tenantId } = ctx.params + const tenant = tenantId ? await deps.tenantService.get(tenantId) : undefined + + if (!tenant) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.InvalidRequest, + 'Not Found' + ) + } let noInteractionRequired: boolean try { noInteractionRequired = canSkipInteraction(deps.config, ctx.request.body) @@ -124,14 +139,15 @@ async function createGrant( ) } if (noInteractionRequired) { - await createApprovedGrant(deps, ctx) + await createApprovedGrant(deps, tenantId, ctx) } else { - await createPendingGrant(deps, ctx) + await createPendingGrant(deps, tenant, ctx) } } async function createApprovedGrant( deps: ServiceDependencies, + tenantId: string, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -140,7 +156,7 @@ async function createApprovedGrant( let grant: Grant let accessToken: AccessToken try { - grant = await grantService.create(body, trx) + grant = await grantService.create(body, tenantId, trx) accessToken = await deps.accessTokenService.create(grant.id, trx) await trx.commit() } catch (err) { @@ -179,6 +195,7 @@ async function createApprovedGrant( async function createPendingGrant( deps: ServiceDependencies, + tenant: Tenant, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -191,6 +208,14 @@ async function createPendingGrant( ) } + if (!isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidRequest, + 'invalid tenant' + ) + } + const client = await deps.clientService.get(body.client) if (!client) { throw new GNAPServerRouteError( @@ -203,7 +228,7 @@ async function createPendingGrant( const trx = await Grant.startTransaction() try { - const grant = await grantService.create(body, trx) + const grant = await grantService.create(body, tenant.id, trx) const interaction = await interactionService.create(grant.id, trx) await trx.commit() @@ -454,7 +479,7 @@ async function revokeGrant( deps: ServiceDependencies, ctx: RevokeContext ): Promise { - const { id: continueId } = ctx.params + const { id: continueId, tenantId } = ctx.params const { grantService, logger } = deps const continueToken = (ctx.headers['authorization'] as string)?.split( 'GNAP ' @@ -475,7 +500,7 @@ async function revokeGrant( ) } - const revoked = await grantService.revokeGrant(grant.id) + const revoked = await grantService.revokeGrant(grant.id, tenantId) if (!revoked) { throw new GNAPServerRouteError( 404, diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index a48e7f057f..7f4316ad09 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -24,20 +24,27 @@ import { AccessToken } from '../accessToken/model' import { Interaction, InteractionState } from '../interaction/model' import { Pagination, SortOrder } from '../shared/baseModel' import { getPageTests } from '../shared/baseModel.test' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Grant Service', (): void => { let deps: IocContract let appContainer: TestContainer let grantService: GrantService - let trx: Knex.Transaction + let knex: Knex + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) - + knex = appContainer.knex grantService = await deps.use('grantService') }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -48,13 +55,14 @@ describe('Grant Service', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createGrant(deps), + createModel: () => createGrant(deps, tenant.id), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => grantService.getPage(pagination, undefined, sortOrder) }) }) describe('grant flow', (): void => { + let tenant: Tenant let grant: Grant let access: Access let accessToken: AccessToken @@ -62,6 +70,7 @@ describe('Grant Service', (): void => { const CLIENT = faker.internet.url({ appendSlash: false }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -70,7 +79,8 @@ describe('Grant Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) await Interaction.query().insert({ @@ -126,7 +136,7 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: GrantState.Approved, @@ -140,7 +150,7 @@ describe('Grant Service', (): void => { }) await expect( - Access.query(trx) + Access.query(knex) .where({ grantId: grant.id }) @@ -170,7 +180,7 @@ describe('Grant Service', (): void => { interact } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: expectedState, @@ -179,7 +189,7 @@ describe('Grant Service', (): void => { }) await expect( - Access.query(trx) + Access.query(knex) .where({ grantId: grant.id }) @@ -266,16 +276,13 @@ describe('Grant Service', (): void => { interact: undefined } - const grant1 = await grantService.create(grantRequest) - + const grant1 = await grantService.create(grantRequest, tenant.id) await grant1 .$query() .patch({ finalizationReason: GrantFinalization.Issued }) - const grant2 = await grantService.create(grantRequest) - - const grant3 = await grantService.create(grantRequest) - + const grant2 = await grantService.create(grantRequest, tenant.id) + const grant3 = await grantService.create(grantRequest, tenant.id) await grant3 .$query() .patch({ finalizationReason: GrantFinalization.Revoked }) @@ -349,9 +356,11 @@ describe('Grant Service', (): void => { describe('revoke', (): void => { test('Can revoke a grant', async (): Promise => { - await expect(grantService.revokeGrant(grant.id)).resolves.toEqual(true) + await expect( + grantService.revokeGrant(grant.id, tenant.id) + ).resolves.toEqual(true) - const revokedGrant = await Grant.query(trx).findById(grant.id) + const revokedGrant = await Grant.query(knex).findById(grant.id) expect(revokedGrant?.state).toEqual(GrantState.Finalized) expect(revokedGrant?.finalizationReason).toEqual( GrantFinalization.Revoked @@ -371,7 +380,9 @@ describe('Grant Service', (): void => { }) test('Can "revoke" unknown grant', async (): Promise => { - await expect(grantService.revokeGrant(v4())).resolves.toEqual(false) + await expect( + grantService.revokeGrant(v4(), tenant.id) + ).resolves.toEqual(false) }) }) @@ -389,15 +400,15 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) const timeoutMs = 50 const lock = async (): Promise => { - return await Grant.transaction(async (trx) => { - await grantService.lock(grant.id, trx, timeoutMs) + return await Grant.transaction(async (knex) => { + await grantService.lock(grant.id, knex, timeoutMs) await new Promise((resolve) => setTimeout(resolve, timeoutMs + 10)) - await Grant.query(trx).findById(grant.id) + await Grant.query(knex).findById(grant.id) }) } await expect(Promise.all([lock(), lock()])).rejects.toThrowError( @@ -423,7 +434,7 @@ describe('Grant Service', (): void => { ] for (const { identifier, state, finalizationReason } of grantDetails) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) const updatedGrant = await grant .$query() .patchAndFetch({ state, finalizationReason }) diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index 051b3d0984..d3859ef7bc 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -8,7 +8,9 @@ import { GrantState, GrantFinalization, StartMethod, - FinishMethod + FinishMethod, + isGrantWithTenant, + GrantWithTenant } from './model' import { AccessRequest } from '../access/types' import { AccessService } from '../access/service' @@ -24,8 +26,12 @@ interface GrantFilter { export interface GrantService { getByIdWithAccess(grantId: string): Promise - create(grantRequest: GrantRequest, trx?: Transaction): Promise - markPending(grantId: string, trx?: Transaction): Promise + create( + grantRequest: GrantRequest, + tenantId: string, + trx?: Transaction + ): Promise + markPending(grantId: string, trx?: Transaction): Promise approve(grantId: string, trx?: Transaction): Promise finalize(grantId: string, reason: GrantFinalization): Promise getByContinue( @@ -33,7 +39,7 @@ export interface GrantService { continueToken: string, options?: GetByContinueOpts ): Promise - revokeGrant(grantId: string): Promise + revokeGrant(grantId: string, tenantId?: string): Promise getPage( pagination?: Pagination, filter?: GrantFilter, @@ -115,8 +121,8 @@ export async function createGrantService({ } return { getByIdWithAccess: (grantId: string) => getByIdWithAccess(grantId), - create: (grantRequest: GrantRequest, trx?: Transaction) => - create(deps, grantRequest, trx), + create: (grantRequest: GrantRequest, tenantId: string, trx?: Transaction) => + create(deps, grantRequest, tenantId, trx), markPending: (grantId: string, trx?: Transaction) => markPending(deps, grantId, trx), approve: (grantId: string) => approve(grantId), @@ -126,7 +132,8 @@ export async function createGrantService({ continueToken: string, opts: GetByContinueOpts ) => getByContinue(continueId, continueToken, opts), - revokeGrant: (grantId) => revokeGrant(deps, grantId), + revokeGrant: (grantId: string, tenantId?: string) => + revokeGrant(deps, grantId, tenantId), getPage: (pagination?, filter?, sortOrder?) => getGrantsPage(deps, pagination, filter, sortOrder), updateLastContinuedAt: (id) => updateLastContinuedAt(id), @@ -149,12 +156,17 @@ async function markPending( deps: ServiceDependencies, id: string, trx?: Transaction -): Promise { +): Promise { const grantTrx = trx || (await deps.knex.transaction()) try { - const grant = await Grant.query(trx).patchAndFetchById(id, { - state: GrantState.Pending - }) + const grant = await Grant.query(trx) + .patchAndFetchById(id, { + state: GrantState.Pending + }) + .withGraphFetched('tenant') + + if (!isGrantWithTenant(grant)) + throw new Error('required graph not returned in query') if (!trx) { await grantTrx.commit() @@ -176,20 +188,27 @@ async function finalize(id: string, reason: GrantFinalization): Promise { async function revokeGrant( deps: ServiceDependencies, - grantId: string + grantId: string, + tenantId?: string ): Promise { const { accessTokenService } = deps const trx = await deps.knex.transaction() try { - const grant = await Grant.query(trx) + const queryBuilder = Grant.query(trx) .patchAndFetchById(grantId, { state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) .first() + if (tenantId) { + queryBuilder.andWhere('tenantId', tenantId) + } + + const grant = await queryBuilder + if (!grant) { deps.logger.info( `Could not revoke grant corresponding to grantId: ${grantId}` @@ -211,6 +230,7 @@ async function revokeGrant( async function create( deps: ServiceDependencies, grantRequest: GrantRequest, + tenantId: string, trx?: Transaction ): Promise { const { accessService, knex } = deps @@ -233,7 +253,8 @@ async function create( clientNonce: interact?.finish?.nonce, client, continueId: v4(), - continueToken: generateToken() + continueToken: generateToken(), + tenantId } const grant = await Grant.query(grantTrx).insert(grantData) diff --git a/packages/auth/src/graphql/resolvers/grant.test.ts b/packages/auth/src/graphql/resolvers/grant.test.ts index 50afb44936..dd374b1c74 100644 --- a/packages/auth/src/graphql/resolvers/grant.test.ts +++ b/packages/auth/src/graphql/resolvers/grant.test.ts @@ -20,6 +20,8 @@ import { Grant, Grant as GrantModel } from '../../grant/model' import { getPageTests } from './page.test' import { createGrant } from '../../tests/grant' import { GraphQLErrorCode } from '../errors' +import { Tenant } from '../../tenant/model' +import { generateTenant } from '../../tests/tenant' const responseHandler = (query: ApolloQueryResult): GrantsConnection => { if (query.data) { @@ -32,12 +34,17 @@ const responseHandler = (query: ApolloQueryResult): GrantsConnection => { describe('Grant Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer + let tenant: Tenant beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -50,7 +57,7 @@ describe('Grant Resolvers', (): void => { describe('Grants Queries', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createGrant(deps) as Promise, + createModel: () => createGrant(deps, tenant.id) as Promise, pagedQuery: 'grants' }) @@ -58,7 +65,7 @@ describe('Grant Resolvers', (): void => { const grants: GrantModel[] = [] for (let i = 0; i < 2; i++) { - grants[1 - i] = await createGrant(deps) + grants[1 - i] = await createGrant(deps, tenant.id) } const query = await appContainer.apolloClient @@ -106,7 +113,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } }) @@ -170,7 +177,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } @@ -231,7 +238,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -280,7 +287,7 @@ describe('Grant Resolvers', (): void => { { state: GrantState.Approved } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -339,7 +346,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -402,7 +409,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -454,7 +461,7 @@ describe('Grant Resolvers', (): void => { describe('Grant By id Queries', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can get a grant', async (): Promise => { @@ -528,7 +535,7 @@ describe('Grant Resolvers', (): void => { describe('Revoke grant', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can revoke a grant', async (): Promise => { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 2315fe083b..bb49692b73 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,8 @@ import { import { createInteractionService } from './interaction/service' import { getTokenIntrospectionOpenAPI } from 'token-introspection' import { Redis } from 'ioredis' +import { createTenantService } from './tenant/service' +import { createTenantRoutes } from './tenant/routes' const container = initIocContainer(Config) const app = new App(container) @@ -137,6 +139,16 @@ export function initIocContainer( } ) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + } + ) + container.singleton('grantRoutes', async (deps: IocContract) => { return createGrantRoutes({ grantService: await deps.use('grantService'), @@ -144,6 +156,7 @@ export function initIocContainer( accessTokenService: await deps.use('accessTokenService'), accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -156,12 +169,23 @@ export function initIocContainer( accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), grantService: await deps.use('grantService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) } ) + container.singleton( + 'tenantRoutes', + async (deps: IocContract) => { + return createTenantRoutes({ + tenantService: await deps.use('tenantService'), + logger: await deps.use('logger') + }) + } + ) + container.singleton('openApi', async () => { const authServerSpec = await getAuthServerOpenAPI() const idpSpec = await createOpenAPI( @@ -304,6 +328,9 @@ export const start = async ( await app.startIntrospectionServer(config.introspectionPort) logger.info(`Introspection server listening on ${app.getIntrospectionPort()}`) + + await app.startServiceAPIServer(config.serviceAPIPort) + logger.info(`Service API server listening on ${app.getServiceAPIPort()}`) } // If this script is run directly, start the server diff --git a/packages/auth/src/interaction/routes.test.ts b/packages/auth/src/interaction/routes.test.ts index a078cf15d6..3ceafc97e4 100644 --- a/packages/auth/src/interaction/routes.test.ts +++ b/packages/auth/src/interaction/routes.test.ts @@ -1,5 +1,5 @@ import { v4 } from 'uuid' -import * as crypto from 'crypto' +import crypto from 'crypto' import jestOpenAPI from 'jest-openapi' import { IocContract } from '@adonisjs/fold' import assert from 'assert' @@ -26,6 +26,8 @@ import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const BASE_GRANT_ACCESS = { type: AccessType.IncomingPayment, @@ -39,11 +41,15 @@ describe('Interaction Routes', (): void => { let interactionRoutes: InteractionRoutes let config: IAppConfig + let tenant: Tenant let grant: Grant let interaction: Interaction beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insert(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -81,6 +87,56 @@ describe('Interaction Routes', (): void => { }) describe('Client - interaction start', (): void => { + test.each` + isFinishableGrant | description + ${true} | ${'finishable'} + ${false} | ${'unfinishable'} + `( + 'Interaction start fails if tenant for $description grant has no configured idp', + async ({ isFinishableGrant }): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: unconfiguredTenant.id, + noFinishMethod: !isFinishableGrant + }) + ) + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { id: interaction.id, nonce: interaction.nonce } + ) + + if (isFinishableGrant) { + const redirectSpy = jest.spyOn(ctx, 'redirect') + await expect(interactionRoutes.start(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(302) + + assert.ok(grant.finishUri) + const redirectUrl = new URL(grant.finishUri) + redirectUrl.searchParams.set('result', GNAPErrorCode.RequestDenied) + redirectUrl.searchParams.set('message', 'internal server error') + expect(redirectSpy).toHaveBeenCalledWith(redirectUrl.toString()) + } else { + await expect(interactionRoutes.start(ctx)).rejects.toMatchObject({ + status: 500, + code: GNAPErrorCode.RequestDenied, + message: 'internal server error' + }) + } + } + ) test('Interaction start fails if interaction is invalid', async (): Promise => { const ctx = createContext( { @@ -108,6 +164,7 @@ describe('Interaction Routes', (): void => { async ({ isFinishableGrant }): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: !isFinishableGrant, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked @@ -162,6 +219,7 @@ describe('Interaction Routes', (): void => { async ({ isFinishableGrant }): Promise => { const grant = await Grant.query().insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: !isFinishableGrant }) ) @@ -226,12 +284,12 @@ describe('Interaction Routes', (): void => { }, url: `/interact/${interaction.id}/${interaction.nonce}` }, - { id: interaction.id, nonce: interaction.nonce } + { id: interaction.id, nonce: interaction.nonce, tenantId: tenant.id } ) assert.ok(interaction.id) - - const redirectUrl = new URL(config.identityServerUrl) + assert.ok(tenant.idpConsentUrl) + const redirectUrl = new URL(tenant.idpConsentUrl) redirectUrl.searchParams.set('interactId', interaction.id) const redirectSpy = jest.spyOn(ctx, 'redirect') @@ -322,10 +380,12 @@ describe('Interaction Routes', (): void => { describe('Interactions for grant with finish method', (): void => { test('Can finish accepted interaction', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Approved - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Approved + }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -382,7 +442,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Finalized, finalizationReason: GrantFinalization.Rejected }) @@ -465,6 +525,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish interaction with revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -542,7 +603,7 @@ describe('Interaction Routes', (): void => { let grantWithoutFinish: Grant beforeEach(async (): Promise => { grantWithoutFinish = await Grant.query().insert( - generateBaseGrant({ noFinishMethod: true }) + generateBaseGrant({ noFinishMethod: true, tenantId: tenant.id }) ) await Access.query().insert({ @@ -580,6 +641,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -619,6 +681,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish invalid interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -659,6 +722,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish interaction with revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked @@ -723,14 +787,17 @@ describe('Interaction Routes', (): void => { }) describe('IDP - Grant details', (): void => { + let tenant: Tenant let grant: Grant let access: Access let interaction: Interaction - beforeAll(async (): Promise => { - grant = await Grant.query().insert({ - ...generateBaseGrant() - }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) + + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) access = await Access.query().insertAndFetch({ ...BASE_GRANT_ACCESS, @@ -748,7 +815,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -777,7 +844,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -792,11 +859,13 @@ describe('Interaction Routes', (): void => { }) test('Cannot get grant details for revoked grant', async (): Promise => { - const revokedGrant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const revokedGrant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) const interaction = await Interaction.query().insert( generateBaseInteraction(revokedGrant) @@ -806,7 +875,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -840,13 +909,34 @@ describe('Interaction Routes', (): void => { }) }) + test('Cannot get grant details with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + }, + url: `/grant/${interaction.id}/${interaction.nonce}`, + method: 'GET' + }, + { id: interaction.id, nonce: interaction.nonce } + ) + + await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidRequest, + message: 'invalid x-idp-secret' + }) + }) + test('Cannot get grant details for nonexistent interaction', async (): Promise => { const ctx = createContext( { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -864,7 +954,7 @@ describe('Interaction Routes', (): void => { let pendingGrant: Grant beforeEach(async (): Promise => { pendingGrant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Pending }) @@ -874,6 +964,46 @@ describe('Interaction Routes', (): void => { }) }) + test('cannot accept/reject interaction with unconfigured tenant', async (): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: unconfiguredTenant.id }) + ) + + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + + const ctx = createContext( + { + url: `/grant/${interaction.id}/${interaction.nonce}/accept`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': tenant.idpSecret + } + }, + { + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept + } + ) + + await expect( + interactionRoutes.acceptOrReject(ctx) + ).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.UnknownInteraction, + message: 'unknown interaction' + }) + }) + test('cannot accept/reject interaction without secret', async (): Promise => { const ctx = createContext( { @@ -898,6 +1028,31 @@ describe('Interaction Routes', (): void => { }) }) + test('cannot accept/reject interacetion with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + } + }, + { + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept + } + ) + + await expect( + interactionRoutes.acceptOrReject(ctx) + ).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidInteraction, + message: 'invalid x-idp-secret' + }) + }) + test('can accept interaction', async (): Promise => { const ctx = createContext( { @@ -906,7 +1061,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -940,7 +1095,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { id: interactId, nonce } @@ -963,7 +1118,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -996,7 +1151,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { diff --git a/packages/auth/src/interaction/routes.ts b/packages/auth/src/interaction/routes.ts index 2c4673f4f7..669443fd7c 100644 --- a/packages/auth/src/interaction/routes.ts +++ b/packages/auth/src/interaction/routes.ts @@ -19,11 +19,14 @@ import { import { toOpenPaymentsAccess } from '../access/model' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' +import { isTenantWithIdp } from '../tenant/model' interface ServiceDependencies extends BaseService { grantService: GrantService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -83,6 +86,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): InteractionRoutes { @@ -94,6 +98,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger: log, config } @@ -111,13 +116,32 @@ async function getGrantDetails( ctx: GetContext ): Promise { const secret = ctx.headers?.['x-idp-secret'] - const { config, interactionService, accessService } = deps + const { interactionService, accessService, tenantService } = deps const { id: interactId, nonce } = ctx.params + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction || isRevokedGrant(interaction.grant)) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + // Tenant should exist as it is a foreign key requirement on grants + const tenant = await tenantService.get(interaction.grant.tenantId) + if (!tenant || !isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 500, + GNAPErrorCode.InvalidRequest, + 'internal server error' + ) + } + if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -126,14 +150,6 @@ async function getGrantDetails( 'invalid x-idp-secret' ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction || isRevokedGrant(interaction.grant)) { - throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' - ) - } const access = await accessService.getByGrant(interaction.grantId) @@ -165,7 +181,7 @@ async function startInteraction( ) const { id: interactId, nonce } = ctx.params const { clientName, clientUri } = ctx.query - const { config, interactionService, grantService, logger } = deps + const { interactionService, grantService, logger } = deps const interaction = await interactionService.getBySession(interactId, nonce) if (!interaction) { @@ -199,12 +215,16 @@ async function startInteraction( const trx = await Interaction.startTransaction() try { - await grantService.markPending(interaction.id, trx) + // Grant and Tenant should exist, as one is a foreign key requirement on interactions and the other a foreign key requirement on that grant. + const grant = await grantService.markPending(interaction.grant.id, trx) await trx.commit() + if (!isTenantWithIdp(grant.tenant)) throw new Error('invalid interaction') + const { idpConsentUrl } = grant.tenant + ctx.session.nonce = interaction.nonce - const interactionUrl = new URL(config.identityServerUrl) + const interactionUrl = new URL(idpConsentUrl) interactionUrl.searchParams.set('interactId', interaction.id) interactionUrl.searchParams.set('nonce', interaction.nonce) interactionUrl.searchParams.set('clientName', clientName as string) @@ -243,14 +263,32 @@ async function handleInteractionChoice( ctx: ChooseContext ): Promise { const { id: interactId, nonce, choice } = ctx.params - const { config, interactionService, logger } = deps + const { interactionService, logger } = deps const secret = ctx.headers['x-idp-secret'] + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + const tenant = await deps.tenantService.get(interaction.grant.tenantId) + + if (!tenant || !isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -260,67 +298,58 @@ async function handleInteractionChoice( ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction) { + const { grant } = interaction + // If grant was already rejected or revoked + if ( + grant.state === GrantState.Finalized && + grant.finalizationReason !== GrantFinalization.Issued + ) { throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' + 401, + GNAPErrorCode.UserDenied, + 'user denied interaction' ) - } else { - const { grant } = interaction - // If grant was already rejected or revoked - if ( - grant.state === GrantState.Finalized && - grant.finalizationReason !== GrantFinalization.Issued - ) { - throw new GNAPServerRouteError( - 401, - GNAPErrorCode.UserDenied, - 'user denied interaction' - ) - } - - // If grant is otherwise not pending interaction - if ( - interaction.state !== InteractionState.Pending || - isInteractionExpired(interaction) - ) { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidInteraction, - 'invalid interaction' - ) - } + } - if (choice === InteractionChoices.Accept) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction approved' - ) - await interactionService.approve(interactId) - } else if (choice === InteractionChoices.Reject) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction rejected' - ) - await interactionService.deny(interactId) - } else { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidRequest, - 'invalid interaction choice' - ) - } + // If grant is otherwise not pending interaction + if ( + interaction.state !== InteractionState.Pending || + isInteractionExpired(interaction) + ) { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidInteraction, + 'invalid interaction' + ) + } - ctx.status = 202 + if (choice === InteractionChoices.Accept) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction approved' + ) + await interactionService.approve(interactId) + } else if (choice === InteractionChoices.Reject) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction rejected' + ) + await interactionService.deny(interactId) + } else { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidRequest, + 'invalid interaction choice' + ) } + + ctx.status = 202 } async function handleFinishableGrant( diff --git a/packages/auth/src/interaction/service.test.ts b/packages/auth/src/interaction/service.test.ts index 8e09a567d4..236650a9e2 100644 --- a/packages/auth/src/interaction/service.test.ts +++ b/packages/auth/src/interaction/service.test.ts @@ -17,6 +17,8 @@ import { Access } from '../access/model' import { Interaction, InteractionState } from './model' import { InteractionService } from './service' import { generateNonce, generateToken } from '../shared/utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const CLIENT = faker.internet.url({ appendSlash: false }) const BASE_GRANT_ACCESS = { @@ -30,6 +32,7 @@ describe('Interaction Service', (): void => { let interactionService: InteractionService let interaction: Interaction let grant: Grant + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -39,6 +42,7 @@ describe('Interaction Service', (): void => { }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -47,7 +51,8 @@ describe('Interaction Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) interaction = await Interaction.query().insert({ @@ -75,7 +80,9 @@ describe('Interaction Service', (): void => { describe('create', (): void => { test('can create an interaction', async (): Promise => { - const grant = await Grant.query().insert(generateBaseGrant()) + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) const interaction = await interactionService.create(grant.id) diff --git a/packages/auth/src/interaction/service.ts b/packages/auth/src/interaction/service.ts index 61f9606891..b68afa16b1 100644 --- a/packages/auth/src/interaction/service.ts +++ b/packages/auth/src/interaction/service.ts @@ -103,11 +103,12 @@ async function getBySession( id: string, nonce: string ): Promise { - const interaction = await Interaction.query() + const queryBuilder = Interaction.query() .findById(id) .where('nonce', nonce) .withGraphFetched('grant') + const interaction = await queryBuilder if (!interaction || !isInteractionWithGrant(interaction)) { return undefined } diff --git a/packages/auth/src/shared/utils.test.ts b/packages/auth/src/shared/utils.test.ts index a8eb0ca487..a611234bec 100644 --- a/packages/auth/src/shared/utils.test.ts +++ b/packages/auth/src/shared/utils.test.ts @@ -6,7 +6,7 @@ import { Config } from '../config/app' import { createContext } from '../tests/context' import { generateApiSignature } from '../tests/apiSignature' import { initIocContainer } from '..' -import { verifyApiSignature } from './utils' +import { verifyApiSignature, isValidDateString } from './utils' import { TestContainer, createTestApp } from '../tests/app' describe('utils', (): void => { @@ -150,4 +150,19 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('isValidDateString', () => { + test.each([ + ['2024-12-05T15:10:09.545Z', true], + ['2024-12-05', true], + ['invalid-date', false], // Invalid date string + ['2024-12-05T25:10:09.545Z', false], // Invalid date string (invalid hour) + ['"2024-12-05T15:10:09.545Z"', false], // Improperly formatted string + ['', false], // Empty string + [null, false], // Null value + [undefined, false] // Undefined value + ])('should return %p for input %p', (input, expected) => { + expect(isValidDateString(input!)).toBe(expected) + }) + }) }) diff --git a/packages/auth/src/shared/utils.ts b/packages/auth/src/shared/utils.ts index 4e14d070b6..358ce0c7e7 100644 --- a/packages/auth/src/shared/utils.ts +++ b/packages/auth/src/shared/utils.ts @@ -104,3 +104,8 @@ export async function verifyApiSignature( return verifyApiSignatureDigest(signature as string, ctx.request, config) } + +// Intended for Date strings like "2024-12-05T15:10:09.545Z" (e.g., from new Date().toISOString()) +export function isValidDateString(date: string): boolean { + return !isNaN(Date.parse(date)) +} diff --git a/packages/auth/src/signature/middleware.test.ts b/packages/auth/src/signature/middleware.test.ts index df93af0bd5..595e3fefe0 100644 --- a/packages/auth/src/signature/middleware.test.ts +++ b/packages/auth/src/signature/middleware.test.ts @@ -36,6 +36,8 @@ import { ContinueContext, CreateContext } from '../grant/routes' import { Interaction, InteractionState } from '../interaction/model' import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Signature Service', (): void => { let deps: IocContract @@ -66,6 +68,7 @@ describe('Signature Service', (): void => { let managementId: string let tokenManagementUrl: string let accessTokenService: AccessTokenService + let tenant: Tenant const generateBaseGrant = (overrides?: Partial) => ({ state: GrantState.Pending, @@ -112,7 +115,10 @@ describe('Signature Service', (): void => { }) beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -338,12 +344,13 @@ describe('Signature Service', (): void => { }) test('middleware fails if grant is revoked', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant({ + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) - }) + ) const ctx = await createContextWithSigHeaders( { diff --git a/packages/auth/src/tenant/model.ts b/packages/auth/src/tenant/model.ts new file mode 100644 index 0000000000..74bbc22101 --- /dev/null +++ b/packages/auth/src/tenant/model.ts @@ -0,0 +1,21 @@ +import { BaseModel } from '../shared/baseModel' + +export class Tenant extends BaseModel { + public static get tableName(): string { + return 'tenants' + } + + public idpConsentUrl?: string + public idpSecret?: string + + public deletedAt?: Date +} + +export interface TenantWithIdp extends Tenant { + idpConsentUrl: NonNullable + idpSecret: NonNullable +} + +export function isTenantWithIdp(tenant: Tenant): tenant is TenantWithIdp { + return !!(tenant.idpConsentUrl && tenant.idpSecret) +} diff --git a/packages/auth/src/tenant/routes.test.ts b/packages/auth/src/tenant/routes.test.ts new file mode 100644 index 0000000000..2e3226d4e4 --- /dev/null +++ b/packages/auth/src/tenant/routes.test.ts @@ -0,0 +1,227 @@ +import { IocContract } from '@adonisjs/fold' +import { v4 } from 'uuid' + +import { createContext } from '../tests/context' +import { createTestApp, TestContainer } from '../tests/app' +import { Config } from '../config/app' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { truncateTables } from '../tests/tableManager' +import { + CreateContext, + UpdateContext, + DeleteContext, + TenantRoutes, + createTenantRoutes, + GetContext +} from './routes' +import { TenantService } from './service' +import { Tenant } from './model' + +describe('Tenant Routes', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantRoutes: TenantRoutes + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + const logger = await deps.use('logger') + + tenantRoutes = createTenantRoutes({ + tenantService, + logger + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('get', (): void => { + test('Gets a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(200) + expect(ctx.body).toEqual({ + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + }) + }) + + test('Returns 404 when getting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + expect(ctx.body).toBeUndefined() + }) + }) + + describe('create', (): void => { + test('Creates a tenant', async (): Promise => { + const tenantData = { + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + {} + ) + ctx.request.body = tenantData + + await expect(tenantRoutes.create(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const tenant = await Tenant.query().findById(tenantData.id) + expect(tenant).toBeDefined() + expect(tenant?.idpConsentUrl).toBe(tenantData.idpConsentUrl) + expect(tenant?.idpSecret).toBe(tenantData.idpSecret) + }) + }) + + describe('update', (): void => { + test('Updates a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const updateData = { + idpConsentUrl: 'https://example.com/new-consent', + idpSecret: 'newSecret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = updateData + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const updatedTenant = await Tenant.query().findById(tenant.id) + expect(updatedTenant?.idpConsentUrl).toBe(updateData.idpConsentUrl) + expect(updatedTenant?.idpSecret).toBe(updateData.idpSecret) + }) + + test('Returns 404 when updating non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { + idpConsentUrl: 'https://example.com/new-consent' + } + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) + + describe('delete', (): void => { + test('Deletes a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + + const deletedTenant = await Tenant.query().findById(tenant.id) + expect(deletedTenant?.deletedAt).not.toBeNull() + }) + + test('Returns 404 when deleting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) +}) diff --git a/packages/auth/src/tenant/routes.ts b/packages/auth/src/tenant/routes.ts new file mode 100644 index 0000000000..895e07cf2c --- /dev/null +++ b/packages/auth/src/tenant/routes.ts @@ -0,0 +1,147 @@ +import { ParsedUrlQuery } from 'querystring' +import { AppContext } from '../app' +import { TenantService } from './service' +import { BaseService } from '../shared/baseService' +import { Tenant } from './model' +import { isValidDateString } from '../shared/utils' + +type TenantRequest = Exclude< + AppContext['request'], + 'body' +> & { + body: BodyT + query: ParsedUrlQuery & QueryT +} + +type TenantContext = Exclude< + AppContext, + 'request' +> & { + request: TenantRequest +} + +interface CreateTenantBody { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +type UpdateTenantBody = Partial> + +interface TenantParams { + id: string +} + +interface TenantResponse { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export type GetContext = TenantContext +export type CreateContext = TenantContext +export type UpdateContext = TenantContext +export type DeleteContext = TenantContext<{ deletedAt: string }, TenantParams> + +export interface TenantRoutes { + get(ctx: GetContext): Promise + create(ctx: CreateContext): Promise + update(ctx: UpdateContext): Promise + delete(ctx: DeleteContext): Promise +} + +interface ServiceDependencies extends BaseService { + tenantService: TenantService +} + +export function createTenantRoutes({ + tenantService, + logger +}: ServiceDependencies): TenantRoutes { + const log = logger.child({ + service: 'TenantRoutes' + }) + + const deps = { tenantService, logger: log } + + return { + get: (ctx: GetContext) => getTenant(deps, ctx), + create: (ctx: CreateContext) => createTenant(deps, ctx), + update: (ctx: UpdateContext) => updateTenant(deps, ctx), + delete: (ctx: DeleteContext) => deleteTenant(deps, ctx) + } +} + +async function createTenant( + deps: ServiceDependencies, + ctx: CreateContext +): Promise { + const { body } = ctx.request + + await deps.tenantService.create(body) + + ctx.status = 204 +} + +async function updateTenant( + deps: ServiceDependencies, + ctx: UpdateContext +): Promise { + const { id } = ctx.params + const { body } = ctx.request + const tenant = await deps.tenantService.update(id, body) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function deleteTenant( + deps: ServiceDependencies, + ctx: DeleteContext +): Promise { + const { id } = ctx.params + const { deletedAt: deletedAtString } = ctx.request.body + + if (!isValidDateString(deletedAtString)) { + ctx.status = 400 + return + } + const deletedAt = new Date(deletedAtString) + + const deleted = await deps.tenantService.delete(id, deletedAt) + + if (!deleted) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function getTenant( + deps: ServiceDependencies, + ctx: GetContext +): Promise { + const { id } = ctx.params + const tenant = await deps.tenantService.get(id) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 200 + ctx.body = toTenantResponse(tenant) +} + +function toTenantResponse(tenant: Tenant): TenantResponse { + return { + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + } +} diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts new file mode 100644 index 0000000000..d5b68ffc15 --- /dev/null +++ b/packages/auth/src/tenant/service.test.ts @@ -0,0 +1,181 @@ +import { faker } from '@faker-js/faker' +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { TenantService } from './service' +import { Tenant } from './model' + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + tenantService = await deps.use('tenantService') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('create', (): void => { + test('creates a tenant', async (): Promise => { + const tenantData = createTenantData() + const tenant = await tenantService.create(tenantData) + + expect(tenant).toEqual({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: undefined + }) + }) + + test('fails to create tenant with duplicate id', async (): Promise => { + const tenantData = createTenantData() + await tenantService.create(tenantData) + + await expect(tenantService.create(tenantData)).rejects.toThrow() + }) + }) + + describe('get', (): void => { + test('retrieves an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const tenant = await tenantService.get(created.id) + expect(tenant).toEqual({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const tenant = await tenantService.get(faker.string.uuid()) + expect(tenant).toBeUndefined() + }) + + test('returns undefined for soft deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + await tenantService.delete(created.id, new Date()) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + }) + }) + + describe('update', (): void => { + test('updates an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toEqual({ + id: created.id, + ...updateData, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('can update partial fields', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url() + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toEqual({ + id: created.id, + idpConsentUrl: updateData.idpConsentUrl, + idpSecret: created.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const updated = await tenantService.update(faker.string.uuid(), { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + + test('returns undefined for soft-deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + await tenantService.delete(created.id, new Date()) + + const updated = await tenantService.update(created.id, { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + }) + + describe('delete', (): void => { + test('soft deletes an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const result = await tenantService.delete(created.id, new Date()) + expect(result).toBe(true) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + + const deletedTenant = await Tenant.query() + .findById(created.id) + .whereNotNull('deletedAt') + expect(deletedTenant).toBeDefined() + expect(deletedTenant?.deletedAt).toBeDefined() + }) + + test('returns false for non-existent tenant', async (): Promise => { + const result = await tenantService.delete(faker.string.uuid(), new Date()) + expect(result).toBe(false) + }) + + test('returns false for already deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + await tenantService.delete(created.id, new Date()) + const secondDelete = await tenantService.delete(created.id, new Date()) + expect(secondDelete).toBe(false) + }) + }) +}) diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts new file mode 100644 index 0000000000..27e562e397 --- /dev/null +++ b/packages/auth/src/tenant/service.ts @@ -0,0 +1,83 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Tenant } from './model' + +export interface CreateOptions { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export interface TenantService { + create(input: CreateOptions): Promise + get(id: string): Promise + update( + id: string, + input: Partial> + ): Promise + delete(id: string, deletedAt: Date): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createTenantService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'TenantService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (input: CreateOptions) => createTenant(deps, input), + get: (id: string) => getTenant(deps, id), + update: (id: string, input: Partial>) => + updateTenant(deps, id, input), + delete: (id: string, deletedAt: Date) => deleteTenant(deps, id, deletedAt) + } +} + +async function createTenant( + deps: ServiceDependencies, + input: CreateOptions +): Promise { + return await Tenant.query(deps.knex).insert(input) +} + +async function getTenant( + deps: ServiceDependencies, + id: string +): Promise { + return await Tenant.query(deps.knex) + .findById(id) + .whereNull('deletedAt') + .first() +} + +async function updateTenant( + deps: ServiceDependencies, + id: string, + input: Partial> +): Promise { + return await Tenant.query(deps.knex) + .whereNull('deletedAt') + .patchAndFetchById(id, input) +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string, + deletedAt: Date +): Promise { + const deleted = await Tenant.query(deps.knex) + .patch({ deletedAt }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +} diff --git a/packages/auth/src/tests/app.ts b/packages/auth/src/tests/app.ts index 87338ba17c..aedf90fad3 100644 --- a/packages/auth/src/tests/app.ts +++ b/packages/auth/src/tests/app.ts @@ -34,6 +34,7 @@ export const createTestApp = async ( config.introspectionPort = 0 config.adminPort = 0 config.interactionPort = 0 + config.serviceAPIPort = 0 const logger = createLogger({ transport: { diff --git a/packages/auth/src/tests/grant.ts b/packages/auth/src/tests/grant.ts index 6949cd4913..76aac6b24d 100644 --- a/packages/auth/src/tests/grant.ts +++ b/packages/auth/src/tests/grant.ts @@ -16,6 +16,7 @@ const CLIENT = faker.internet.url({ appendSlash: false }) export async function createGrant( deps: IocContract, + tenantId: string, options?: { identifier?: string } ): Promise { const grantService = await deps.use('grantService') @@ -36,34 +37,40 @@ export async function createGrant( } } - const grantOrError = await grantService.create({ - ...BASE_GRANT_REQUEST, - access_token: { - access: [ - { - ...BASE_GRANT_ACCESS, - type: AccessType.IncomingPayment - } - ] - } - }) + const grantOrError = await grantService.create( + { + ...BASE_GRANT_REQUEST, + access_token: { + access: [ + { + ...BASE_GRANT_ACCESS, + type: AccessType.IncomingPayment + } + ] + } + }, + tenantId + ) return grantOrError } export interface GenerateBaseGrantOptions { + tenantId: string state?: GrantState finalizationReason?: GrantFinalization noFinishMethod?: boolean } -export const generateBaseGrant = (options: GenerateBaseGrantOptions = {}) => { +export const generateBaseGrant = (options: GenerateBaseGrantOptions) => { const { + tenantId, state = GrantState.Processing, finalizationReason = undefined, noFinishMethod = false } = options return { + tenantId, state, finalizationReason, startMethod: [StartMethod.Redirect], diff --git a/packages/auth/src/tests/tenant.ts b/packages/auth/src/tests/tenant.ts new file mode 100644 index 0000000000..5c146eeca6 --- /dev/null +++ b/packages/auth/src/tests/tenant.ts @@ -0,0 +1,11 @@ +import crypto from 'crypto' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' + +export function generateTenant() { + return { + id: v4(), + idpConsentUrl: faker.internet.url(), + idpSecret: crypto.randomBytes(8).toString('base64') + } +} diff --git a/packages/backend/jest.config.js b/packages/backend/jest.config.js index 492a6e5e30..cdb688ff95 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -4,24 +4,12 @@ const baseConfig = require('../../jest.config.base.js') // eslint-disable-next-line @typescript-eslint/no-var-requires const packageName = require('./package.json').name -process.env.LOG_LEVEL = 'silent' -process.env.INSTANCE_NAME = 'Rafiki' -process.env.KEY_ID = 'myKey' -process.env.OPEN_PAYMENTS_URL = 'http://127.0.0.1:3000' -process.env.ILP_CONNECTOR_URL = 'http://127.0.0.1:3002' -process.env.ILP_ADDRESS = 'test.rafiki' -process.env.AUTH_SERVER_GRANT_URL = 'http://127.0.0.1:3006' -process.env.AUTH_SERVER_INTROSPECTION_URL = 'http://127.0.0.1:3007/' -process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' -process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' -process.env.USE_TIGERBEETLE = false -process.env.ENABLE_TELEMETRY = false - module.exports = { ...baseConfig, clearMocks: true, testTimeout: 30000, roots: [`/packages/${packageName}`], + setupFiles: [`/packages/${packageName}/jest.env.js`], globalSetup: `/packages/${packageName}/jest.setup.ts`, globalTeardown: `/packages/${packageName}/jest.teardown.js`, testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js new file mode 100644 index 0000000000..14b2fdd839 --- /dev/null +++ b/packages/backend/jest.env.js @@ -0,0 +1,18 @@ +process.env.LOG_LEVEL = 'silent' +process.env.INSTANCE_NAME = 'Rafiki' +process.env.KEY_ID = 'myKey' +process.env.OPEN_PAYMENTS_URL = 'http://127.0.0.1:3000' +process.env.ILP_CONNECTOR_URL = 'http://127.0.0.1:3002' +process.env.ILP_ADDRESS = 'test.rafiki' +process.env.AUTH_SERVER_GRANT_URL = 'http://127.0.0.1:3006' +process.env.AUTH_SERVER_INTROSPECTION_URL = 'http://127.0.0.1:3007/' +process.env.AUTH_SERVICE_API_URL = 'http://127.0.0.1:3011' +process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' +process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' +process.env.USE_TIGERBEETLE = false +process.env.ENABLE_TELEMETRY = false +process.env.AUTH_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' +process.env.AUTH_ADMIN_API_SECRET = 'test-secret' +process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' +process.env.API_SECRET = 'KQEXlZO65jUJXakXnLxGO7dk387mt71G9tZ42rULSNU=' +process.env.EXCHANGE_RATES_URL = 'http://example.com/rates' diff --git a/packages/backend/jest.setup.ts b/packages/backend/jest.setup.ts index ff90fa720f..ef4340581d 100644 --- a/packages/backend/jest.setup.ts +++ b/packages/backend/jest.setup.ts @@ -1,5 +1,6 @@ import { knex } from 'knex' import { GenericContainer, Wait } from 'testcontainers' +require('./jest.env') // set environment variables const POSTGRES_PORT = 5432 const REDIS_PORT = 6379 diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js new file mode 100644 index 0000000000..e6fc77e934 --- /dev/null +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -0,0 +1,26 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('tenants', function (table) { + table.uuid('id').notNullable().primary() + table.string('email') + table.string('apiSecret').notNullable() + table.string('idpConsentUrl') + table.string('idpSecret') + table.string('publicName') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/backend/migrations/20241205153035_seed_operator_tenant.js b/packages/backend/migrations/20241205153035_seed_operator_tenant.js new file mode 100644 index 0000000000..6af21658c1 --- /dev/null +++ b/packages/backend/migrations/20241205153035_seed_operator_tenant.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const OPERATOR_TENANT_ID = process.env['OPERATOR_TENANT_ID'] +const OPERATOR_API_SECRET = process.env['API_SECRET'] + +exports.up = function (knex) { + if (!OPERATOR_TENANT_ID || !OPERATOR_API_SECRET) { + throw new Error( + 'Could not seed operator tenant. Please configure OPERATOR_TENANT_ID and API_SECRET environment variables' + ) + } + + return knex.raw(` + INSERT INTO "tenants" ("id", "apiSecret") + VALUES ('${OPERATOR_TENANT_ID}', '${OPERATOR_API_SECRET}') + `) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.raw(` + TRUNCATE "tenants" + `) +} diff --git a/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js b/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js new file mode 100644 index 0000000000..2f16118d46 --- /dev/null +++ b/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('quotes', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "quotes" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('quotes', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('quotes', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js new file mode 100644 index 0000000000..e20da3b522 --- /dev/null +++ b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('assets', (table) => { + table.uuid('tenantId').references('tenants.id').index() + table.dropUnique(['code', 'scale']) + table.unique(['code', 'scale', 'tenantId']) + }) + .then(() => { + return knex.raw( + `UPDATE "assets" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('assets', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.dropUnique(['code', 'scale', 'tenantId']) + table.dropColumn('tenantId') + table.unique(['code', 'scale']) + }) +} diff --git a/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js b/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js new file mode 100644 index 0000000000..34092bbb49 --- /dev/null +++ b/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('outgoingPayments', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "outgoingPayments" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('outgoingPayments', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('outgoingPayments', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js new file mode 100644 index 0000000000..a3f21e9904 --- /dev/null +++ b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('walletAddresses', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "walletAddresses" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('walletAddresses', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('walletAddresses', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20250117205655_create_tenant_settings_table.js b/packages/backend/migrations/20250117205655_create_tenant_settings_table.js new file mode 100644 index 0000000000..649e811da5 --- /dev/null +++ b/packages/backend/migrations/20250117205655_create_tenant_settings_table.js @@ -0,0 +1,22 @@ +exports.up = function (knex) { + return knex.schema.createTable('tenantSettings', function (table) { + table.uuid('id').notNullable().primary() + table.string('key').notNullable().index() + table.string('value').notNullable() + + table + .uuid('tenantId') + .notNullable() + .references('tenants.id') + .index() + .onDelete('CASCADE') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenantSettings') +} diff --git a/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js b/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js new file mode 100644 index 0000000000..06c5c0dfd8 --- /dev/null +++ b/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('incomingPayments', function (table) { + table.uuid('tenantId') + table.foreign('tenantId').references('id').inTable('tenants') + }) + .then(() => { + knex.raw( + `UPDATE "incomingPayments" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + knex.schema.alterTable('incomingPayments', function (table) { + table.uuid('tenantId').notNullable() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('incomingPayments', function (table) { + table.dropForeign('tenantId') + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/migrations/20250214141958_add_tenant_to_peer.js b/packages/backend/migrations/20250214141958_add_tenant_to_peer.js new file mode 100644 index 0000000000..149017e2bc --- /dev/null +++ b/packages/backend/migrations/20250214141958_add_tenant_to_peer.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('peers', function (table) { + table.uuid('tenantId') + table.foreign('tenantId').references('id').inTable('tenants') + }) + .then(() => { + knex.raw( + `UPDATE "peers" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + knex.schema.alterTable('peers', function (table) { + table.uuid('tenantId').notNullable() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('peers', (table) => { + table.dropForeign('tenantId') + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/migrations/20250218191304_add_tenant_to_webhooks.js b/packages/backend/migrations/20250218191304_add_tenant_to_webhooks.js new file mode 100644 index 0000000000..5dcf7ec6ad --- /dev/null +++ b/packages/backend/migrations/20250218191304_add_tenant_to_webhooks.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('webhookEvents', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "webhookEvents" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('webhookEvents', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('webhookEvents', function (table) { + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/migrations/20250301103930_rename_wallet_address_url_to_address.js b/packages/backend/migrations/20250301103930_rename_wallet_address_url_to_address.js new file mode 100644 index 0000000000..b5bdee041d --- /dev/null +++ b/packages/backend/migrations/20250301103930_rename_wallet_address_url_to_address.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('walletAddresses', (table) => { + table.renameColumn('url', 'address') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('walletAddresses', (table) => { + table.renameColumn('address', 'url') + }) +} diff --git a/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js b/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js new file mode 100644 index 0000000000..41d3ce1402 --- /dev/null +++ b/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('tenantSettings', function (table) { + table.unique(['tenantId', 'key']) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('tenantSettings', function (table) { + table.dropUnique(['tenantId', 'key']) + }) +} diff --git a/packages/backend/migrations/20250409175316_create_webhooks_table.js b/packages/backend/migrations/20250409175316_create_webhooks_table.js new file mode 100644 index 0000000000..ac9538afaf --- /dev/null +++ b/packages/backend/migrations/20250409175316_create_webhooks_table.js @@ -0,0 +1,66 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .createTable('webhooks', function (table) { + table.uuid('id').notNullable().primary() + table + .uuid('eventId') + .notNullable() + .references('webhookEvents.id') + .onDelete('CASCADE') + .index() + table + .uuid('recipientTenantId') + .notNullable() + .references('tenants.id') + .onDelete('CASCADE') + .index() + + table.integer('attempts').notNullable().defaultTo(0) + table.integer('statusCode').nullable() + + table.timestamp('processAt').nullable().defaultTo(knex.fn.now()) + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + + table.index('processAt') + }) + .then(() => { + return knex.raw( + `INSERT INTO "webhooks" (id, "eventId", "recipientTenantId", attempts, "statusCode", "processAt") select gen_random_uuid(), id as "eventId", "tenantId" as "recipientTenantId", attempts, "statusCode", "processAt" from "webhookEvents"` + ) + }) + .then(() => { + return knex.schema.alterTable('webhookEvents', (table) => { + table.dropColumn('attempts') + table.dropColumn('statusCode') + table.dropColumn('processAt') + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema + .alterTable('webhookEvents', function (table) { + table.integer('attempts').notNullable().defaultTo(0) + table.integer('statusCode').nullable() + table.timestamp('processAt').nullable().defaultTo(knex.fn.now()) + table.index('processAt') + }) + .then(() => { + return knex.raw( + `UPDATE "webhookEvents" SET "attempts" = (SELECT "attempts" from "webhooks" where "recipientTenantId" = "webhookEvents"."tenantId" AND "eventId" = "webhookEvents"."id"), "statusCode" = (SELECT "statusCode" from "webhooks" where "recipientTenantId" = "webhookEvents"."tenantId" AND "eventId" = "webhookEvents"."id"), "processAt" = (SELECT "processAt" from "webhooks" where "recipientTenantId" = "webhookEvents"."tenantId" AND "eventId" = "webhookEvents"."id")` + ) + }) + .then(() => { + return knex.schema.dropTableIfExists('webhooks') + }) +} diff --git a/packages/backend/migrations/20250625121526_replace_combined_payments_view.js b/packages/backend/migrations/20250625121526_replace_combined_payments_view.js new file mode 100644 index 0000000000..eefa0e39dd --- /dev/null +++ b/packages/backend/migrations/20250625121526_replace_combined_payments_view.js @@ -0,0 +1,65 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.raw(` + DROP VIEW IF EXISTS "combinedPaymentsView"; + CREATE VIEW "combinedPaymentsView" AS + SELECT + "id", + "walletAddressId", + "state", + "client", + "createdAt", + "updatedAt", + "metadata", + "tenantId", + 'INCOMING' AS "type" + FROM "incomingPayments" + UNION ALL + SELECT + "id", + "walletAddressId", + "state", + "client", + "createdAt", + "updatedAt", + "metadata", + "tenantId", + 'OUTGOING' AS "type" + FROM "outgoingPayments" + `) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.raw(` + DROP VIEW IF EXISTS "combinedPaymentsView"; + CREATE VIEW "combinedPaymentsView" AS + SELECT + "id", + "walletAddressId", + "state", + "client", + "createdAt", + "updatedAt", + "metadata", + 'INCOMING' AS "type" + FROM "incomingPayments" + UNION ALL + SELECT + "id", + "walletAddressId", + "state", + "client", + "createdAt", + "updatedAt", + "metadata", + 'OUTGOING' AS "type" + FROM "outgoingPayments" + `) +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 36be8e9eb7..a94a1ef918 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@adonisjs/fold": "^8.2.0", + "@apollo/client": "^3.11.8", "@apollo/server": "^4.11.2", "@as-integrations/koa": "^1.1.1", "@escape.tech/graphql-armor": "^2.4.0", diff --git a/packages/backend/src/accounting/psql/balance.test.ts b/packages/backend/src/accounting/psql/balance.test.ts index 559bbb229c..09b33b518f 100644 --- a/packages/backend/src/accounting/psql/balance.test.ts +++ b/packages/backend/src/accounting/psql/balance.test.ts @@ -4,7 +4,7 @@ import { createTestApp, TestContainer } from '../../tests/app' import { Config } from '../../config/app' import { initIocContainer } from '../../' import { Asset } from '../../asset/model' -import { randomAsset } from '../../tests/asset' +import { createAsset } from '../../tests/asset' import { truncateTables } from '../../tests/tableManager' import { LedgerAccount } from './ledger-account/model' import { createLedgerAccount } from '../../tests/ledgerAccount' @@ -12,17 +12,21 @@ import { getAccountBalances } from './balance' import { ServiceDependencies } from './service' import { LedgerTransferState } from '../service' import { createLedgerTransfer } from '../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' describe('Balances', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { + config: await deps.use('config'), logger: await deps.use('logger'), knex: await deps.use('knex'), telemetry: await deps.use('telemetry') @@ -31,12 +35,12 @@ describe('Balances', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await createAsset(deps) }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -48,7 +52,7 @@ describe('Balances', (): void => { let peerAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await createAsset(deps) ;[account, peerAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex) diff --git a/packages/backend/src/accounting/psql/ledger-account/index.test.ts b/packages/backend/src/accounting/psql/ledger-account/index.test.ts index 4d5b0140ec..2ee8b38dc7 100644 --- a/packages/backend/src/accounting/psql/ledger-account/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-account/index.test.ts @@ -13,17 +13,21 @@ import { ForeignKeyViolationError } from 'objection' import { createLedgerAccount } from '../../../tests/ledgerAccount' import { ServiceDependencies } from '../service' import { createAccount, getLiquidityAccount } from '.' +import { AppServices } from '../../../app' +import { IocContract } from '@adonisjs/fold' describe('Ledger Account', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { + config: await deps.use('config'), logger: await deps.use('logger'), knex: await deps.use('knex'), telemetry: await deps.use('telemetry') @@ -32,12 +36,15 @@ describe('Ledger Account', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts index 4a75ce60cc..0cb0b73786 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts @@ -22,17 +22,21 @@ import { } from '.' import { ServiceDependencies } from '../service' import { TransferError } from '../../errors' +import { AppServices } from '../../../app' +import { IocContract } from '@adonisjs/fold' describe('Ledger Transfer', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { + config: await deps.use('config'), logger: await deps.use('logger'), knex: await deps.use('knex'), telemetry: await deps.use('telemetry') @@ -45,7 +49,10 @@ describe('Ledger Transfer', (): void => { let settlementAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[account, peerAccount, settlementAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex), @@ -62,7 +69,7 @@ describe('Ledger Transfer', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts index 63dbcd8cef..9b1853b26e 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts @@ -9,14 +9,17 @@ import { LedgerAccount, LedgerAccountType } from '../ledger-account/model' import { createLedgerAccount } from '../../../tests/ledgerAccount' import { LedgerTransferState } from '../../service' import { createLedgerTransfer } from '../../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' describe('Ledger Transfer Model', (): void => { + let deps: IocContract let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) knex = appContainer.knex }) @@ -25,7 +28,10 @@ describe('Ledger Transfer Model', (): void => { let debitAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[creditAccount, debitAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount( @@ -41,7 +47,7 @@ describe('Ledger Transfer Model', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/service.test.ts b/packages/backend/src/accounting/psql/service.test.ts index 316cdf811e..c0f544abe9 100644 --- a/packages/backend/src/accounting/psql/service.test.ts +++ b/packages/backend/src/accounting/psql/service.test.ts @@ -54,12 +54,15 @@ describe('Psql Accounting Service', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -892,7 +895,10 @@ describe('Psql Accounting Service', (): void => { const timeout = 10 // 10 seconds beforeEach(async (): Promise => { - const sourceAsset = await assetService.create(randomAsset()) + const sourceAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(sourceAsset)) sourceAccount = await accountFactory.build({ @@ -902,7 +908,10 @@ describe('Psql Accounting Service', (): void => { const destinationAsset = sameAsset ? sourceAsset - : await assetService.create(randomAsset()) + : await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(destinationAsset)) diff --git a/packages/backend/src/accounting/psql/service.ts b/packages/backend/src/accounting/psql/service.ts index fe67500eb6..ea33ad032a 100644 --- a/packages/backend/src/accounting/psql/service.ts +++ b/packages/backend/src/accounting/psql/service.ts @@ -35,9 +35,11 @@ import { } from './ledger-transfer' import { LedgerTransfer, LedgerTransferType } from './ledger-transfer/model' import { TelemetryService } from '../../telemetry/service' +import { IAppConfig } from '../../config/app' export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex + config: IAppConfig telemetry: TelemetryService withdrawalThrottleDelay?: number } diff --git a/packages/backend/src/accounting/service.ts b/packages/backend/src/accounting/service.ts index bdbbc3c2fc..d355d9c014 100644 --- a/packages/backend/src/accounting/service.ts +++ b/packages/backend/src/accounting/service.ts @@ -1,6 +1,7 @@ import { TransactionOrKnex } from 'objection' import { BaseService } from '../shared/baseService' import { TransferError, isTransferError } from './errors' +import { IAppConfig } from '../config/app' export enum LiquidityAccountType { ASSET = 'ASSET', @@ -27,14 +28,20 @@ export interface LiquidityAccountAsset { code?: string scale?: number ledger: number - onDebit?: (options: OnDebitOptions) => Promise + onDebit?: ( + options: OnDebitOptions, + config: IAppConfig + ) => Promise } export interface LiquidityAccount { id: string asset: LiquidityAccountAsset onCredit?: (options: OnCreditOptions) => Promise - onDebit?: (options: OnDebitOptions) => Promise + onDebit?: ( + options: OnDebitOptions, + config: IAppConfig + ) => Promise } export interface OnCreditOptions { @@ -133,6 +140,7 @@ export interface TransferToCreate { } export interface BaseAccountingServiceDependencies extends BaseService { + config: IAppConfig withdrawalThrottleDelay?: number } @@ -195,15 +203,19 @@ export async function createAccountToAccountTransfer( } const onDebit = async ( - account: LiquidityAccount | LiquidityAccount['asset'] + account: LiquidityAccount | LiquidityAccount['asset'], + config: IAppConfig ) => { if (account.onDebit) { const balance = await getAccountBalance(account.id) if (balance === undefined) throw new Error('undefined account balance') - await account.onDebit({ - balance - }) + await account.onDebit( + { + balance + }, + config + ) } } @@ -213,8 +225,8 @@ export async function createAccountToAccountTransfer( if (error) return error await Promise.all([ - onDebit(sourceAccount), - onDebit(destinationAccount.asset) + onDebit(sourceAccount, deps.config), + onDebit(destinationAccount.asset, deps.config) ]) if (destinationAccount.onCredit) { diff --git a/packages/backend/src/accounting/tigerbeetle/service.test.ts b/packages/backend/src/accounting/tigerbeetle/service.test.ts index acf2a3fcd8..f199eac2b1 100644 --- a/packages/backend/src/accounting/tigerbeetle/service.test.ts +++ b/packages/backend/src/accounting/tigerbeetle/service.test.ts @@ -57,7 +57,7 @@ describe('TigerBeetle Accounting Service', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/accounting/tigerbeetle/service.ts b/packages/backend/src/accounting/tigerbeetle/service.ts index 4bdf94c4a8..5d2f752c82 100644 --- a/packages/backend/src/accounting/tigerbeetle/service.ts +++ b/packages/backend/src/accounting/tigerbeetle/service.ts @@ -32,6 +32,7 @@ import { } from './transfers' import { toTigerBeetleId } from './utils' import { TelemetryService } from '../../telemetry/service' +import { IAppConfig } from '../../config/app' export enum TigerBeetleAccountCode { LIQUIDITY_WEB_MONETIZATION = 1, @@ -68,6 +69,7 @@ export const convertToTigerBeetleTransferCode: { } export interface ServiceDependencies extends BaseService { + config: IAppConfig tigerBeetle: Client telemetry: TelemetryService withdrawalThrottleDelay?: number diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 2dd309b37e..9952324002 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -70,7 +70,8 @@ import { applyMiddleware } from 'graphql-middleware' import { Redis } from 'ioredis' import { idempotencyGraphQLMiddleware, - lockGraphQLMutationMiddleware + lockGraphQLMutationMiddleware, + setForTenantIdGraphQLMutationMiddleware } from './graphql/middleware' import { createRedisDataStore } from './middleware/cache/data-stores/redis' import { createRedisLock } from './middleware/lock/redis' @@ -85,7 +86,6 @@ import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' import { openPaymentsServerErrorMiddleware } from './open_payments/route-errors' -import { verifyApiSignature } from './shared/utils' import { WalletAddress } from './open_payments/wallet_address/model' import { getWalletAddressUrlFromIncomingPayment, @@ -102,6 +102,17 @@ import { LoggingPlugin } from './graphql/plugin' import { LocalPaymentService } from './payment-method/local/service' import { GrantService } from './open_payments/grant/service' import { AuthServerService } from './open_payments/authServer/service' +import { Tenant } from './tenants/model' +import { + getTenantFromApiSignature, + TenantApiSignatureResult +} from './shared/utils' +import { TenantService } from './tenants/service' +import { AuthServiceClient } from './auth-service-client/client' +import { TenantSettingService } from './tenants/settings/service' +import { StreamCredentialsService } from './payment-method/ilp/stream-credentials/service' +import { PaymentMethodProviderService } from './payment-method/provider/service' + export interface AppContextData { logger: Logger container: AppContainer @@ -215,6 +226,15 @@ type ContextType = T extends ( const WALLET_ADDRESS_PATH = '/:walletAddressPath+' +export interface TenantedApolloContext extends ApolloContext { + tenant: Tenant + isOperator: boolean +} + +export interface ForTenantIdContext extends TenantedApolloContext { + forTenantId?: string +} + export interface AppServices { logger: Promise telemetry: Promise @@ -257,6 +277,11 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + tenantService: Promise + authServiceClient: AuthServiceClient + tenantSettingService: Promise + streamCredentialsService: Promise + paymentMethodProviderService: Promise } export type AppContainer = IocContract @@ -325,7 +350,8 @@ export class App { ), idempotencyGraphQLMiddleware( createRedisDataStore(redis, this.config.graphQLIdempotencyKeyTtlMs) - ) + ), + setForTenantIdGraphQLMutationMiddleware() ) // Setup Armor @@ -386,19 +412,58 @@ export class App { } ) - if (this.config.adminApiSecret) { - koa.use(async (ctx, next: Koa.Next): Promise => { - if (!(await verifyApiSignature(ctx, this.config))) { + let tenantApiSignatureResult: TenantApiSignatureResult + const tenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { + ctx.throw(401, 'Unauthorized') + } else { + tenantApiSignatureResult = { + tenant: result.tenant, + isOperator: result.isOperator ? true : false + } + } + return next() + } + + const testTenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + if (ctx.headers['tenant-id']) { + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get( + ctx.headers['tenant-id'] as string + ) + + if (tenant) { + tenantApiSignatureResult = { + tenant, + isOperator: tenant.apiSecret === this.config.adminApiSecret + } + } else { ctx.throw(401, 'Unauthorized') } - return next() - }) + } + return next() } + // For tests, we still need to get the tenant in the middleware, but + // we don't need to verify the signature nor prevent replay attacks + koa.use( + this.config.env !== 'test' + ? tenantSignatureMiddleware + : testTenantSignatureMiddleware + ) + koa.use( koaMiddleware(this.apolloServer, { - context: async (): Promise => { + context: async (): Promise => { return { + ...tenantApiSignatureResult, container: this.container, logger: await this.container.use('logger') } @@ -441,7 +506,7 @@ export class App { // POST /incoming-payments // Create incoming payment router.post>( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -468,7 +533,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -492,7 +557,7 @@ export class App { // POST /outgoing-payment // Create outgoing payment router.post>( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -519,7 +584,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -543,7 +608,7 @@ export class App { // POST /quotes // Create quote router.post>( - '/quotes', + '/:tenantId/quotes', createValidatorMiddleware< ContextType> >( @@ -567,7 +632,7 @@ export class App { // GET /incoming-payments/{id} // Read incoming payment router.get( - '/incoming-payments/:id', + '/:tenantId/incoming-payments/:id', createValidatorMiddleware< ContextType >( @@ -592,7 +657,7 @@ export class App { // POST /incoming-payments/{id}/complete // Complete incoming payment router.post( - '/incoming-payments/:id/complete', + '/:tenantId/incoming-payments/:id/complete', createValidatorMiddleware>( resourceServerSpec, { @@ -614,7 +679,7 @@ export class App { // GET /outgoing-payments/{id} // Read outgoing payment router.get( - '/outgoing-payments/:id', + '/:tenantId/outgoing-payments/:id', createValidatorMiddleware>( resourceServerSpec, { @@ -636,7 +701,7 @@ export class App { // GET /quotes/{id} // Read quote router.get( - '/quotes/:id', + '/:tenantId/quotes/:id', createValidatorMiddleware>( resourceServerSpec, { diff --git a/packages/backend/src/asset/errors.ts b/packages/backend/src/asset/errors.ts index 36334ab1fc..011137eab4 100644 --- a/packages/backend/src/asset/errors.ts +++ b/packages/backend/src/asset/errors.ts @@ -3,7 +3,8 @@ import { GraphQLErrorCode } from '../graphql/errors' export enum AssetError { DuplicateAsset = 'DuplicateAsset', UnknownAsset = 'UnknownAsset', - CannotDeleteInUseAsset = 'CannotDeleteInUseAsset' + CannotDeleteInUseAsset = 'CannotDeleteInUseAsset', + NoRatesForAsset = 'NoRatesForAsset' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -15,7 +16,8 @@ export const errorToCode: { } = { [AssetError.UnknownAsset]: GraphQLErrorCode.NotFound, [AssetError.DuplicateAsset]: GraphQLErrorCode.Duplicate, - [AssetError.CannotDeleteInUseAsset]: GraphQLErrorCode.Forbidden + [AssetError.CannotDeleteInUseAsset]: GraphQLErrorCode.Forbidden, + [AssetError.NoRatesForAsset]: GraphQLErrorCode.Forbidden } export const errorToMessage: { @@ -23,5 +25,6 @@ export const errorToMessage: { } = { [AssetError.UnknownAsset]: 'Asset not found', [AssetError.DuplicateAsset]: 'Asset already exists', - [AssetError.CannotDeleteInUseAsset]: 'Cannot delete! Asset in use.' + [AssetError.CannotDeleteInUseAsset]: 'Cannot delete! Asset in use.', + [AssetError.NoRatesForAsset]: 'Cannot create! Exchange rates URL not defined.' } diff --git a/packages/backend/src/asset/model.test.ts b/packages/backend/src/asset/model.test.ts index 461fb043e5..0e01a18af1 100644 --- a/packages/backend/src/asset/model.test.ts +++ b/packages/backend/src/asset/model.test.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex' import { AssetService } from './service' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { createTestApp, TestContainer } from '../tests/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' @@ -25,7 +25,7 @@ describe('Models', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -35,9 +35,12 @@ describe('Models', (): void => { describe('Asset Model', (): void => { describe('onDebit', (): void => { let asset: Asset + let config: IAppConfig beforeEach(async (): Promise => { + config = await deps.use('config') const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, liquidityThreshold: BigInt(100) } const assetOrError = await assetService.create(options) @@ -53,13 +56,13 @@ describe('Models', (): void => { `( 'creates webhook event if balance=$balance <= liquidityThreshold', async ({ balance }): Promise => { - await asset.onDebit({ balance }) + await asset.onDebit({ balance }, config) const event = ( - await AssetEvent.query(knex).where( - 'type', - AssetEventType.LiquidityLow - ) + await AssetEvent.query(knex) + .where('type', AssetEventType.LiquidityLow) + .withGraphFetched('webhooks') )[0] + expect(event.webhooks).toHaveLength(1) expect(event).toMatchObject({ type: AssetEventType.LiquidityLow, data: { @@ -71,12 +74,21 @@ describe('Models', (): void => { }, liquidityThreshold: asset.liquidityThreshold?.toString(), balance: balance.toString() - } + }, + webhooks: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + eventId: event.id, + recipientTenantId: Config.operatorTenantId, + processAt: expect.any(Date), + attempts: 0 + }) + ]) }) } ) test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { - await asset.onDebit({ balance: BigInt(110) }) + await asset.onDebit({ balance: BigInt(110) }, config) await expect( AssetEvent.query(knex).where('type', AssetEventType.LiquidityLow) ).resolves.toEqual([]) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index 62237fcd20..62364528d3 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -1,7 +1,9 @@ import { QueryContext } from 'objection' import { LiquidityAccount, OnDebitOptions } from '../accounting/service' import { BaseModel } from '../shared/baseModel' -import { WebhookEvent } from '../webhook/model' +import { WebhookEvent } from '../webhook/event/model' +import { IAppConfig } from '../config/app' +import { finalizeWebhookRecipients } from '../webhook/service' export class Asset extends BaseModel implements LiquidityAccount { public static get tableName(): string { @@ -13,6 +15,7 @@ export class Asset extends BaseModel implements LiquidityAccount { // TigerBeetle account 2 byte ledger field representing account's asset public readonly ledger!: number + public readonly tenantId!: string public readonly withdrawalThreshold!: bigint | null @@ -28,10 +31,13 @@ export class Asset extends BaseModel implements LiquidityAccount { } } - public async onDebit({ balance }: OnDebitOptions): Promise { + public async onDebit( + { balance }: OnDebitOptions, + config: IAppConfig + ): Promise { if (this.liquidityThreshold !== null) { if (balance <= this.liquidityThreshold) { - await AssetEvent.query().insert({ + await AssetEvent.query().insertGraph({ assetId: this.id, type: AssetEventType.LiquidityLow, data: { @@ -43,7 +49,9 @@ export class Asset extends BaseModel implements LiquidityAccount { }, liquidityThreshold: this.liquidityThreshold, balance - } + }, + tenantId: this.tenantId, + webhooks: finalizeWebhookRecipients([this.tenantId], config) }) } } diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 6c05b221a2..c7b6008c03 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -9,8 +9,8 @@ import { Pagination, SortOrder } from '../shared/baseModel' import { getPageTests } from '../shared/baseModel.test' import { createTestApp, TestContainer } from '../tests/app' import { createAsset, randomAsset } from '../tests/asset' -import { truncateTables } from '../tests/tableManager' -import { Config } from '../config/app' +import { truncateTable, truncateTables } from '../tests/tableManager' +import { Config, IAppConfig } from '../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' @@ -21,6 +21,13 @@ import { isWalletAddressError } from '../open_payments/wallet_address/errors' import { PeerService } from '../payment-method/ilp/peer/service' import { isPeerError } from '../payment-method/ilp/peer/errors' import { CacheDataStore } from '../middleware/cache/data-stores' +import { + CreateOptions, + TenantSettingService +} from '../tenants/settings/service' +import { exchangeRatesSetting } from '../tests/tenantSettings' +import { createTenantSettings } from '../tests/tenantSettings' +import { TenantSettingKeys } from '../tenants/settings/model' describe('Asset Service', (): void => { let deps: IocContract @@ -28,23 +35,56 @@ describe('Asset Service', (): void => { let assetService: AssetService let peerService: PeerService let walletAddressService: WalletAddressService + let tenantSettingService: TenantSettingService + let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) + config = await deps.use('config') assetService = await deps.use('assetService') walletAddressService = await deps.use('walletAddressService') + tenantSettingService = await deps.use('tenantSettingService') peerService = await deps.use('peerService') }) + beforeEach(async (): Promise => { + const createOptions: CreateOptions = { + tenantId: Config.operatorTenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([ + expect.objectContaining({ + tenantId: Config.operatorTenantId, + key: createOptions.setting[0].key, + value: createOptions.setting[0].value + }) + ]) + }) + afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { await appContainer.shutdown() }) + beforeEach(async () => { + await createTenantSettings(deps, { + tenantId: Config.operatorTenantId, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://alice.me' + } + ] + }) + }) + describe('create', (): void => { test.each` withdrawalThreshold | liquidityThreshold @@ -57,6 +97,7 @@ describe('Asset Service', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -80,7 +121,10 @@ describe('Asset Service', (): void => { 'createLiquidityAndLinkedSettlementAccount' ) - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) expect(liquidityAndSettlementSpy).toHaveBeenCalledWith( @@ -100,6 +144,7 @@ describe('Asset Service', (): void => { test('Asset can be created with minimum account withdrawal amount', async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10) } const asset = await assetService.create(options) @@ -113,17 +158,37 @@ describe('Asset Service', (): void => { }) test('Cannot create duplicate asset', async (): Promise => { - const options = randomAsset() + const options = { ...randomAsset(), tenantId: Config.operatorTenantId } await expect(assetService.create(options)).resolves.toMatchObject(options) await expect(assetService.create(options)).resolves.toEqual( AssetError.DuplicateAsset ) }) + test('Cannot create more than one asset if no exchange rates URL is set', async (): Promise => { + await truncateTable(appContainer.knex, 'tenantSettings') + config.operatorExchangeRatesUrl = undefined + const firstAssetOptions = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + await expect( + assetService.create(firstAssetOptions) + ).resolves.toMatchObject(firstAssetOptions) + const secondAssetOptions = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + await expect(assetService.create(secondAssetOptions)).resolves.toEqual( + AssetError.NoRatesForAsset + ) + }) + test('Cannot create asset with scale > 255', async (): Promise => { const options = { code: 'ABC', - scale: 256 + scale: 256, + tenantId: Config.operatorTenantId } await expect(assetService.create(options)).rejects.toThrow( CheckViolationError @@ -133,7 +198,10 @@ describe('Asset Service', (): void => { describe('get', (): void => { test('Can get asset by id', async (): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) await expect(assetService.get(asset.id)).resolves.toEqual(asset) }) @@ -161,6 +229,7 @@ describe('Asset Service', (): void => { beforeEach(async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -186,6 +255,7 @@ describe('Asset Service', (): void => { }): Promise => { const asset = await assetService.update({ id: assetId, + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -198,10 +268,29 @@ describe('Asset Service', (): void => { } ) + test('Cannot update asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.update({ + id: asset.id, + tenantId: uuid(), + withdrawalThreshold: BigInt(10), + liquidityThreshold: null + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) + test('Cannot update unknown asset', async (): Promise => { await expect( assetService.update({ id: uuid(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: null }) @@ -213,7 +302,11 @@ describe('Asset Service', (): void => { getPageTests({ createModel: () => createAsset(deps), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder) + assetService.getPage({ + pagination, + sortOrder, + tenantId: Config.operatorTenantId + }) }) }) @@ -221,7 +314,10 @@ describe('Asset Service', (): void => { test('returns all assets', async (): Promise => { const assets: (Asset | AssetError)[] = [] for (let i = 0; i < 3; i++) { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assets.push(asset) } @@ -235,12 +331,16 @@ describe('Asset Service', (): void => { describe('delete', (): void => { test('Can delete asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -248,18 +348,26 @@ describe('Asset Service', (): void => { }) test('Can delete and restore asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const { code, scale } = newAsset const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) - const restoredAsset = await assetService.create({ code, scale }) + const restoredAsset = await assetService.create({ + code, + scale, + tenantId: newAsset.tenantId + }) assert.ok(!isAssetError(restoredAsset)) expect(restoredAsset.id).toEqual(newAssetId) expect(restoredAsset.code).toEqual(code) @@ -268,24 +376,35 @@ describe('Asset Service', (): void => { }) test('Cannot delete in use asset (wallet)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id // make sure there is at least 1 wallet address using asset - const walletAddress = walletAddressService.create({ - url: 'https://alice.me/.well-known/pay', + const walletAddress = await walletAddressService.create({ + address: 'https://alice.me/.well-known/pay', + tenantId: Config.operatorTenantId, assetId: newAssetId }) assert.ok(!isWalletAddressError(walletAddress)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) test('Cannot delete in use asset (peer)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -310,9 +429,30 @@ describe('Asset Service', (): void => { assert.ok(!isPeerError(peer)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) + + test('Cannot delete asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.delete({ + id: asset.id, + tenantId: uuid(), + deletedAt: new Date() + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) }) }) @@ -333,7 +473,7 @@ describe('Asset Service using Cache', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -352,6 +492,7 @@ describe('Asset Service using Cache', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -380,6 +521,7 @@ describe('Asset Service using Cache', (): void => { const spyCacheUpdateSet = jest.spyOn(assetCache, 'set') const assetUpdate = await assetService.update({ id: asset.id, + tenantId: asset.tenantId, withdrawalThreshold, liquidityThreshold }) @@ -400,6 +542,7 @@ describe('Asset Service using Cache', (): void => { // Delete the asset, and ensure it is not cached: const deletedAsset = await assetService.delete({ id: asset.id, + tenantId: asset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -409,4 +552,26 @@ describe('Asset Service using Cache', (): void => { } ) }) + + test('cannot get asset from cache if incorrect tenantId', async (): Promise => { + const options = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + const spyCacheSet = jest.spyOn(assetCache, 'set') + + const asset = await assetService.create(options) + assert.ok(!isAssetError(asset)) + + expect(spyCacheSet).toHaveBeenCalledWith( + asset.id, + expect.objectContaining(options) + ) + + const spyCacheGet = jest.spyOn(assetCache, 'get') + await expect(assetService.get(asset.id, uuid())).resolves.toEqual(undefined) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(asset.id) + }) }) diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 5dbe8b63c1..96b429294d 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -8,6 +8,9 @@ import { AccountingService, LiquidityAccountType } from '../accounting/service' import { WalletAddress } from '../open_payments/wallet_address/model' import { Peer } from '../payment-method/ilp/peer/model' import { CacheDataStore } from '../middleware/cache/data-stores' +import { TenantSettingService } from '../tenants/settings/service' +import { TenantSettingKeys } from '../tenants/settings/model' +import { IAppConfig } from '../config/app' export interface AssetOptions { code: string @@ -15,39 +18,59 @@ export interface AssetOptions { } export interface CreateOptions extends AssetOptions { + tenantId: string withdrawalThreshold?: bigint liquidityThreshold?: bigint } export interface UpdateOptions { id: string + tenantId: string withdrawalThreshold: bigint | null liquidityThreshold: bigint | null } + export interface DeleteOptions { id: string + tenantId: string deletedAt: Date } +interface GetByCodeAndScaleOptions { + code: string + scale: number + tenantId: string +} + +interface GetPageOptions { + pagination?: Pagination + sortOrder?: SortOrder + tenantId?: string +} + export interface AssetService { create(options: CreateOptions): Promise update(options: UpdateOptions): Promise delete(options: DeleteOptions): Promise - get(id: string): Promise - getByCodeAndScale(code: string, scale: number): Promise - getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise + get(id: string, tenantId?: string): Promise + getByCodeAndScale(options: GetByCodeAndScaleOptions): Promise + getPage(options: GetPageOptions): Promise getAll(): Promise } interface ServiceDependencies extends BaseService { + config: IAppConfig accountingService: AccountingService + tenantSettingService: TenantSettingService assetCache: CacheDataStore } export async function createAssetService({ + config, logger, knex, accountingService, + tenantSettingService, assetCache }: ServiceDependencies): Promise { const log = logger.child({ @@ -55,9 +78,11 @@ export async function createAssetService({ }) const deps: ServiceDependencies = { + config, logger: log, knex, accountingService, + tenantSettingService, assetCache } @@ -65,26 +90,45 @@ export async function createAssetService({ create: (options) => createAsset(deps, options), update: (options) => updateAsset(deps, options), delete: (options) => deleteAsset(deps, options), - get: (id) => getAsset(deps, id), - getByCodeAndScale: (code, scale) => - getAssetByCodeAndScale(deps, code, scale), - getPage: (pagination?, sortOrder?) => - getAssetsPage(deps, pagination, sortOrder), + get: (id, tenantId) => getAsset(deps, id, tenantId), + getByCodeAndScale: (options) => getAssetByCodeAndScale(deps, options), + getPage: (options) => getAssetsPage(deps, options), getAll: () => getAll(deps) } } async function createAsset( deps: ServiceDependencies, - { code, scale, withdrawalThreshold, liquidityThreshold }: CreateOptions + { + code, + scale, + withdrawalThreshold, + liquidityThreshold, + tenantId + }: CreateOptions ): Promise { try { - // check if exists but deleted | by code-scale - const deletedAsset = await Asset.query(deps.knex) - .whereNotNull('deletedAt') - .where('code', code) - .andWhere('scale', scale) - .first() + const assets = await Asset.query(deps.knex) + .andWhere('tenantId', tenantId) + .select('*') + + const sameCodeAssets = assets.find((asset) => asset.code === code) + if (!sameCodeAssets && assets.length > 0) { + const exchangeUrlSetting = await deps.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.EXCHANGE_RATES_URL.name + }) + + const tenantExchangeRatesUrl = exchangeUrlSetting[0]?.value + if (!tenantExchangeRatesUrl && !deps.config.operatorExchangeRatesUrl) { + return AssetError.NoRatesForAsset + } + } + + const deletedAsset = assets.find( + (asset) => + asset.deletedAt !== null && asset.code === code && asset.scale === scale + ) if (deletedAsset) { // if found, enable @@ -105,6 +149,7 @@ async function createAsset( const asset = await Asset.query(trx).insertAndFetch({ code, scale, + tenantId, withdrawalThreshold, liquidityThreshold }) @@ -126,14 +171,18 @@ async function createAsset( async function updateAsset( deps: ServiceDependencies, - { id, withdrawalThreshold, liquidityThreshold }: UpdateOptions + { id, tenantId, withdrawalThreshold, liquidityThreshold }: UpdateOptions ): Promise { if (!deps.knex) { throw new Error('Knex undefined') } try { const asset = await Asset.query(deps.knex) - .patchAndFetchById(id, { withdrawalThreshold, liquidityThreshold }) + .where({ tenantId }) + .patchAndFetchById(id, { + withdrawalThreshold, + liquidityThreshold + }) .throwIfNotFound() await deps.assetCache.set(id, asset) @@ -149,12 +198,20 @@ async function updateAsset( // soft delete async function deleteAsset( deps: ServiceDependencies, - { id, deletedAt }: DeleteOptions + options: DeleteOptions ): Promise { + const { id, tenantId, deletedAt } = options if (!deps.knex) { throw new Error('Knex undefined') } + // Check the correct tenant is requesting delete operation + const existingAsset = await getAsset(deps, id, tenantId) + + if (!existingAsset) { + return AssetError.UnknownAsset + } + await deps.assetCache.delete(id) try { // return error in case there is a peer or wallet address using the asset @@ -182,12 +239,22 @@ async function deleteAsset( async function getAsset( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { const inMem = await deps.assetCache.get(id) - if (inMem) return inMem + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + const asset = await query.findById(id) - const asset = await Asset.query(deps.knex).whereNull('deletedAt').findById(id) if (asset) await deps.assetCache.set(asset.id, asset) return asset @@ -195,24 +262,27 @@ async function getAsset( async function getAssetByCodeAndScale( deps: ServiceDependencies, - code: string, - scale: number + options: GetByCodeAndScaleOptions ): Promise { - return await Asset.query(deps.knex) - .where({ code: code, scale: scale }) - .first() + return await Asset.query(deps.knex).where(options).first() } async function getAssetsPage( deps: ServiceDependencies, - pagination?: Pagination, - sortOrder?: SortOrder + options: GetPageOptions ): Promise { - return await Asset.query(deps.knex) - .whereNull('deletedAt') - .getPage(pagination, sortOrder) + const { tenantId, pagination, sortOrder } = options + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + return await query.getPage(pagination, sortOrder) } +// This used in auto-peering, what to do? async function getAll(deps: ServiceDependencies): Promise { return await Asset.query(deps.knex).whereNull('deletedAt') } diff --git a/packages/backend/src/auth-service-client/client.test.ts b/packages/backend/src/auth-service-client/client.test.ts new file mode 100644 index 0000000000..5a4af980c0 --- /dev/null +++ b/packages/backend/src/auth-service-client/client.test.ts @@ -0,0 +1,123 @@ +import { faker } from '@faker-js/faker' +import nock from 'nock' +import { AuthServiceClient, AuthServiceClientError } from './client' + +describe('AuthServiceClient', () => { + const baseUrl = 'http://auth-service.biz' + let client: AuthServiceClient + + beforeEach(() => { + client = new AuthServiceClient(baseUrl) + nock.cleanAll() + }) + + afterEach(() => { + expect(nock.isDone()).toBeTruthy() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('tenant', () => { + describe('get', () => { + test('retrieves a tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).get(`/tenant/${tenantData.id}`).reply(200, tenantData) + + const tenant = await client.tenant.get(tenantData.id) + expect(tenant).toEqual(tenantData) + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl).get(`/tenant/${id}`).reply(404) + + await expect(client.tenant.get(id)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('create', () => { + test('creates a new tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).post('/tenant', tenantData).reply(204) + + await expect(client.tenant.create(tenantData)).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const tenantData = createTenantData() + + nock(baseUrl) + .post('/tenant', tenantData) + .reply(409, { message: 'Tenant already exists' }) + + await expect(client.tenant.create(tenantData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('update', () => { + test('updates an existing tenant', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + nock(baseUrl).patch(`/tenant/${id}`, updateData).reply(204) + + await expect( + client.tenant.update(id, updateData) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url() + } + + nock(baseUrl) + .patch(`/tenant/${id}`, updateData) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.update(id, updateData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('delete', () => { + test('deletes an existing tenant', async () => { + const id = faker.string.uuid() + + nock(baseUrl).delete(`/tenant/${id}`).reply(204) + + await expect( + client.tenant.delete(id, new Date()) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl) + .delete(`/tenant/${id}`) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.delete(id, new Date())).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + }) +}) diff --git a/packages/backend/src/auth-service-client/client.ts b/packages/backend/src/auth-service-client/client.ts new file mode 100644 index 0000000000..402435446f --- /dev/null +++ b/packages/backend/src/auth-service-client/client.ts @@ -0,0 +1,92 @@ +interface Tenant { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export class AuthServiceClientError extends Error { + constructor( + message: string, + public status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public details?: any + ) { + super(message) + this.status = status + this.details = details + } +} + +export class AuthServiceClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async request(path: string, options: RequestInit): Promise { + options.headers = { 'Content-Type': 'application/json', ...options.headers } + + const response = await fetch(`${this.baseUrl}${path}`, options) + + if (!response.ok) { + let errorDetails + try { + errorDetails = await response.json() + } catch { + errorDetails = { message: response.statusText } + } + + throw new AuthServiceClientError( + `Auth Service Client Error: ${response.status} ${response.statusText}`, + response.status, + errorDetails + ) + } + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + return undefined as T + } + + const contentType = response.headers.get('Content-Type') + if (contentType && contentType.includes('application/json')) { + try { + return (await response.json()) as T + } catch (error) { + throw new AuthServiceClientError( + `Failed to parse JSON response from ${path}`, + response.status + ) + } + } + + return (await response.text()) as T + } + + public tenant = { + get: (id: string) => + this.request(`/tenant/${id}`, { method: 'GET' }), + create: (data: Tenant) => + this.request('/tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + update: (id: string, data: Partial>) => + this.request(`/tenant/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + delete: (id: string, deletedAt: Date) => + this.request(`/tenant/${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deletedAt }) + }) + } +} diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index e2b6c6fa22..a6710ecc8c 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -115,9 +115,8 @@ export const Config = { 5 ), - exchangeRatesUrl: process.env.EXCHANGE_RATES_URL, // optional exchangeRatesLifetime: +(process.env.EXCHANGE_RATES_LIFETIME || 15_000), - + operatorExchangeRatesUrl: process.env.EXCHANGE_RATES_URL, // optional slippage: envFloat('SLIPPAGE', 0.01), quoteLifespan: envInt('QUOTE_LIFESPAN', 5 * 60_000), // milliseconds @@ -126,6 +125,10 @@ export const Config = { authServerGrantUrl: envString('AUTH_SERVER_GRANT_URL'), authServerIntrospectionUrl: envString('AUTH_SERVER_INTROSPECTION_URL'), + authAdminApiUrl: envString('AUTH_ADMIN_API_URL'), + authAdminApiSecret: envString('AUTH_ADMIN_API_SECRET'), + authAdminApiSignatureVersion: envInt('AUTH_ADMIN_API_SIGNATURE_VERSION', 1), + authServiceApiUrl: envString('AUTH_SERVICE_API_URL'), outgoingPaymentWorkers: envInt('OUTGOING_PAYMENT_WORKERS', 1), outgoingPaymentWorkerIdle: envInt('OUTGOING_PAYMENT_WORKER_IDLE', 10), // milliseconds @@ -159,7 +162,7 @@ export const Config = { signatureSecret: process.env.SIGNATURE_SECRET, // optional signatureVersion: envInt('SIGNATURE_VERSION', 1), - adminApiSecret: process.env.API_SECRET, // optional + adminApiSecret: envString('API_SECRET'), adminApiSignatureVersion: envInt('API_SIGNATURE_VERSION', 1), adminApiSignatureTtlSeconds: envInt('ADMIN_API_SIGNATURE_TTL_SECONDS', 30), @@ -193,7 +196,13 @@ export const Config = { 5 ), walletAddressRedirectHtmlPage: process.env.WALLET_ADDRESS_REDIRECT_HTML_PAGE, - localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000) + localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000), + operatorTenantId: envString('OPERATOR_TENANT_ID'), + dbSchema: undefined as string | undefined, + sendTenantWebhooksToOperator: envBool( + 'SEND_TENANT_WEBHOOKS_TO_OPERATOR', + false + ) } function parseRedisTlsConfig( diff --git a/packages/backend/src/fee/model.test.ts b/packages/backend/src/fee/model.test.ts index 35c39d4d1b..b4b044354a 100644 --- a/packages/backend/src/fee/model.test.ts +++ b/packages/backend/src/fee/model.test.ts @@ -23,7 +23,7 @@ describe('Fee Model', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/fee/service.test.ts b/packages/backend/src/fee/service.test.ts index e57068f7e3..5e80acde78 100644 --- a/packages/backend/src/fee/service.test.ts +++ b/packages/backend/src/fee/service.test.ts @@ -4,7 +4,6 @@ import { TestContainer, createTestApp } from '../tests/app' import { initIocContainer } from '..' import { Config } from '../config/app' import { FeeService } from './service' -import { Knex } from 'knex' import { truncateTables } from '../tests/tableManager' import { createAsset } from '../tests/asset' import { Asset } from '../asset/model' @@ -18,14 +17,12 @@ import { Pagination, SortOrder } from '../shared/baseModel' describe('Fee Service', (): void => { let deps: IocContract let appContainer: TestContainer - let knex: Knex let feeService: FeeService let asset: Asset beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) - knex = appContainer.knex feeService = await deps.use('feeService') }) @@ -34,7 +31,7 @@ describe('Fee Service', (): void => { }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 434babb5db..b1a2237a96 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -606,7 +606,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** fees after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** fees after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -729,6 +729,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "withdrawalThreshold", "description": "Minimum amount of liquidity that can be withdrawn from the asset.", @@ -1139,6 +1155,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "withdrawalThreshold", "description": "Minimum amount of liquidity that can be withdrawn from the asset.", @@ -2140,6 +2168,186 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateTenantInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique identifier of the tenant. Must be compliant with uuid v4. Will be generated automatically if not provided.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settings", + "description": "Initial settings for tenant.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateTenantSettingsInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "settings", + "description": "List of a settings for a tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateTenantSettingsMutationResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "settings", + "description": "New tenant settings.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantSetting", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateWalletAddressInput", @@ -2167,6 +2375,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "address", + "description": "Wallet address. This cannot be changed.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "assetId", "description": "Unique identifier of the asset associated with the wallet address. This cannot be changed.", @@ -2208,16 +2432,12 @@ "deprecationReason": null }, { - "name": "url", - "description": "Wallet address URL. This cannot be changed.", + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature.", "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } + "kind": "SCALAR", + "name": "ID", + "ofType": null }, "defaultValue": null, "isDeprecated": false, @@ -2558,6 +2778,34 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeleteTenantMutationResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "success", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "DepositAssetLiquidityInput", @@ -3460,6 +3708,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the incoming payment was created.", @@ -4063,6 +4323,11 @@ "name": "Peer", "ofType": null }, + { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + }, { "kind": "OBJECT", "name": "WalletAddress", @@ -4566,8 +4831,8 @@ "deprecationReason": null }, { - "name": "createWalletAddress", - "description": "Create a new wallet address.", + "name": "createTenant", + "description": "As an operator, create a tenant.", "args": [ { "name": "input", @@ -4577,7 +4842,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "CreateWalletAddressInput", + "name": "CreateTenantInput", "ofType": null } }, @@ -4591,7 +4856,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "CreateWalletAddressMutationResponse", + "name": "TenantMutationResponse", "ofType": null } }, @@ -4599,8 +4864,8 @@ "deprecationReason": null }, { - "name": "createWalletAddressKey", - "description": "Add a public key to a wallet address that is used to verify Open Payments requests.", + "name": "createTenantSettings", + "description": null, "args": [ { "name": "input", @@ -4610,7 +4875,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "CreateWalletAddressKeyInput", + "name": "CreateTenantSettingsInput", "ofType": null } }, @@ -4621,14 +4886,76 @@ ], "type": { "kind": "OBJECT", - "name": "CreateWalletAddressKeyMutationResponse", + "name": "CreateTenantSettingsMutationResponse", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { - "name": "createWalletAddressWithdrawal", + "name": "createWalletAddress", + "description": "Create a new wallet address.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateWalletAddressInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreateWalletAddressMutationResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createWalletAddressKey", + "description": "Add a public key to a wallet address that is used to verify Open Payments requests.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateWalletAddressKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreateWalletAddressKeyMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createWalletAddressWithdrawal", "description": "Withdraw liquidity from a wallet address received via Web Monetization.", "args": [ { @@ -4722,6 +5049,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "deleteTenant", + "description": "Delete a tenant.", + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeleteTenantMutationResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "depositAssetLiquidity", "description": "Deposit asset liquidity.", @@ -5061,6 +5421,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "updateTenant", + "description": "Update a tenant.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateTenantInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantMutationResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "updateWalletAddress", "description": "Update an existing wallet address.", @@ -5364,6 +5757,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Tenant ID of the outgoing payment.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the outgoing payment was created.", @@ -6103,6 +6508,22 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the peer.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -6411,7 +6832,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** assets after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** assets after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -6444,6 +6865,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -6591,6 +7024,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -6680,6 +7125,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -6831,6 +7288,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the peer.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -6904,12 +7373,12 @@ "deprecationReason": null }, { - "name": "walletAddress", - "description": "Fetch a wallet address by its ID.", + "name": "tenant", + "description": "Retrieve a tenant of the instance.", "args": [ { "name": "id", - "description": "Unique identifier of the wallet address.", + "description": "Unique identifier of the tenant.", "type": { "kind": "NON_NULL", "name": null, @@ -6925,49 +7394,24 @@ } ], "type": { - "kind": "OBJECT", - "name": "WalletAddress", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "walletAddressByUrl", - "description": "Get a wallet address by its url if it exists", - "args": [ - { - "name": "url", - "description": "Wallet Address URL.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null } - ], - "type": { - "kind": "OBJECT", - "name": "WalletAddress", - "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { - "name": "walletAddresses", - "description": "Fetch a paginated list of wallet addresses.", + "name": "tenants", + "description": "As an operator, fetch a paginated list of tenants on the instance.", "args": [ { "name": "after", - "description": "Forward pagination: Cursor (wallet address ID) to start retrieving wallet addresses after this point.", + "description": "Forward pagination: Cursor (tenant ID) to start retrieving tenants after this point.", "type": { "kind": "SCALAR", "name": "String", @@ -6979,7 +7423,7 @@ }, { "name": "before", - "description": "Backward pagination: Cursor (wallet address ID) to start retrieving wallet addresses before this point.", + "description": "Backward pagination: Cursor (tenant ID) to start retrieving tenants before this point.", "type": { "kind": "SCALAR", "name": "String", @@ -6991,7 +7435,7 @@ }, { "name": "first", - "description": "Forward pagination: Limit the result to the first **n** wallet addresses after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** tenants after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -7003,7 +7447,7 @@ }, { "name": "last", - "description": "Backward pagination: Limit the result to the last **n** wallet addresses before the `before` cursor.", + "description": "Backward pagination: Limit the result to the last **n** tenants before the `before` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -7015,7 +7459,7 @@ }, { "name": "sortOrder", - "description": "Specify the sort order of wallet addresses based on their creation date, either ascending or descending.", + "description": "Specify the sort order of tenants based on their creation date, either ascending or descending.", "type": { "kind": "ENUM", "name": "SortOrder", @@ -7031,7 +7475,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "WalletAddressesConnection", + "name": "TenantsConnection", "ofType": null } }, @@ -7039,26 +7483,173 @@ "deprecationReason": null }, { - "name": "webhookEvents", - "description": "Fetch a paginated list of webhook events.", + "name": "walletAddress", + "description": "Fetch a wallet address by its ID.", "args": [ { - "name": "after", - "description": "Forward pagination: Cursor (webhook event ID) to start retrieving webhook events after this point.", + "name": "id", + "description": "Unique identifier of the wallet address.", "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "defaultValue": null, "isDeprecated": false, "deprecationReason": null - }, - { - "name": "before", - "description": "Backward pagination: Cursor (webhook event ID) to start retrieving webhook events before this point.", - "type": { - "kind": "SCALAR", + } + ], + "type": { + "kind": "OBJECT", + "name": "WalletAddress", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletAddressByUrl", + "description": "Get a wallet address by its url if it exists", + "args": [ + { + "name": "url", + "description": "Wallet Address URL.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WalletAddress", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletAddresses", + "description": "Fetch a paginated list of wallet addresses.", + "args": [ + { + "name": "after", + "description": "Forward pagination: Cursor (wallet address ID) to start retrieving wallet addresses after this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "before", + "description": "Backward pagination: Cursor (wallet address ID) to start retrieving wallet addresses before this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "first", + "description": "Forward pagination: Limit the result to the first **n** wallet addresses after the `after` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "last", + "description": "Backward pagination: Limit the result to the last **n** wallet addresses before the `before` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sortOrder", + "description": "Specify the sort order of wallet addresses based on their creation date, either ascending or descending.", + "type": { + "kind": "ENUM", + "name": "SortOrder", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletAddressesConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEvents", + "description": "Fetch a paginated list of webhook events.", + "args": [ + { + "name": "after", + "description": "Forward pagination: Cursor (webhook event ID) to start retrieving webhook events after this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "before", + "description": "Backward pagination: Cursor (webhook event ID) to start retrieving webhook events before this point.", + "type": { + "kind": "SCALAR", "name": "String", "ofType": null }, @@ -7113,6 +7704,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -7126,6 +7729,22 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "whoami", + "description": "Determine if the requester has operator permissions", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WhoamiResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -7247,6 +7866,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant under which the quote was created.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the quote was created.", @@ -7605,14 +8240,395 @@ "deprecationReason": null }, { - "name": "fee", - "description": "Fee values", + "name": "fee", + "description": "Fee values", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FeeDetails", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idempotencyKey", + "description": "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Type of fee, either sending or receiving.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeeType", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SetFeeResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "fee", + "description": "The fee that was set.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "SortOrder", + "description": null, + "isOneOf": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ASC", + "description": "Sort the results in ascending order.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DESC", + "description": "Sort the results in descending order.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "isOneOf": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Tenant", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "The date and time that this tenant was created.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": "The date and time that this tenant was deleted.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique identifier of the tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settings", + "description": "List of settings for the tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantSetting", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Model", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantEdge", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "cursor", + "description": "A cursor for paginating through the tenants.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "A tenant node in the list.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantMutationResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "tenant", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantSetting", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "key", + "description": "Key for this setting.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": "Value of a setting for this key.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "key", + "description": "Key for this setting.", "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "INPUT_OBJECT", - "name": "FeeDetails", + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -7621,26 +8637,14 @@ "deprecationReason": null }, { - "name": "idempotencyKey", - "description": "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency).", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "Type of fee, either sending or receiving.", + "name": "value", + "description": "Value of a setting for this key.", "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "ENUM", - "name": "FeeType", + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -7655,60 +8659,53 @@ }, { "kind": "OBJECT", - "name": "SetFeeResponse", + "name": "TenantsConnection", "description": null, "isOneOf": null, "fields": [ { - "name": "fee", - "description": "The fee that was set.", + "name": "edges", + "description": "A list of edges representing tenants and cursors for pagination.", "args": [], "type": { - "kind": "OBJECT", - "name": "Fee", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantEdge", + "ofType": null + } + } + } }, "isDeprecated": false, "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "SortOrder", - "description": null, - "isOneOf": null, - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ASC", - "description": "Sort the results in ascending order.", - "isDeprecated": false, - "deprecationReason": null }, { - "name": "DESC", - "description": "Sort the results in descending order.", + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null } ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "String", - "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", - "isOneOf": null, - "fields": null, "inputFields": null, - "interfaces": null, + "interfaces": [], "enumValues": null, "possibleTypes": null }, @@ -8090,6 +9087,94 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateTenantInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique identifier of the tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "UpdateWalletAddressInput", @@ -8264,6 +9349,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "address", + "description": "Wallet Address.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "asset", "description": "Asset of the wallet address.", @@ -8512,7 +9613,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -8572,17 +9673,13 @@ "deprecationReason": null }, { - "name": "url", - "description": "Wallet Address URL.", + "name": "tenantId", + "description": "Tenant ID of the wallet address.", "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } + "kind": "SCALAR", + "name": "String", + "ofType": null }, "isDeprecated": false, "deprecationReason": null @@ -8617,7 +9714,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** keys after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** keys after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -9124,6 +10221,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Tenant of the webhook event.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "type", "description": "Type of webhook event.", @@ -9272,6 +10385,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "WhoamiResponse", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isOperator", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "WithdrawEventLiquidityInput", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 3c43fbcc4b..35e0959e56 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -364,17 +367,47 @@ export type CreateReceiverResponse = { receiver?: Maybe; }; +export type CreateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['input']; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. Must be compliant with uuid v4. Will be generated automatically if not provided. */ + id?: InputMaybe; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; + /** Initial settings for tenant. */ + settings?: InputMaybe>; +}; + +export type CreateTenantSettingsInput = { + /** List of a settings for a tenant. */ + settings: Array; +}; + +export type CreateTenantSettingsMutationResponse = { + __typename?: 'CreateTenantSettingsMutationResponse'; + /** New tenant settings. */ + settings: Array; +}; + export type CreateWalletAddressInput = { /** Additional properties associated with the wallet address. */ additionalProperties?: InputMaybe>; + /** Wallet address. This cannot be changed. */ + address: Scalars['String']['input']; /** Unique identifier of the asset associated with the wallet address. This cannot be changed. */ assetId: Scalars['String']['input']; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; - /** Wallet address URL. This cannot be changed. */ - url: Scalars['String']['input']; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; }; export type CreateWalletAddressKeyInput = { @@ -440,6 +473,11 @@ export type DeletePeerMutationResponse = { success: Scalars['Boolean']['output']; }; +export type DeleteTenantMutationResponse = { + __typename?: 'DeleteTenantMutationResponse'; + success: Scalars['Boolean']['output']; +}; + export type DepositAssetLiquidityInput = { /** Amount of liquidity to deposit. */ amount: Scalars['UInt64']['input']; @@ -580,6 +618,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -721,6 +761,9 @@ export type Mutation = { createQuote: QuoteResponse; /** Create an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ createReceiver: CreateReceiverResponse; + /** As an operator, create a tenant. */ + createTenant: TenantMutationResponse; + createTenantSettings?: Maybe; /** Create a new wallet address. */ createWalletAddress: CreateWalletAddressMutationResponse; /** Add a public key to a wallet address that is used to verify Open Payments requests. */ @@ -731,6 +774,8 @@ export type Mutation = { deleteAsset: DeleteAssetMutationResponse; /** Delete a peer. */ deletePeer: DeletePeerMutationResponse; + /** Delete a tenant. */ + deleteTenant: DeleteTenantMutationResponse; /** Deposit asset liquidity. */ depositAssetLiquidity?: Maybe; /** @@ -756,6 +801,8 @@ export type Mutation = { updateIncomingPayment: IncomingPaymentResponse; /** Update an existing peer. */ updatePeer: UpdatePeerMutationResponse; + /** Update a tenant. */ + updateTenant: TenantMutationResponse; /** Update an existing wallet address. */ updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ @@ -843,6 +890,16 @@ export type MutationCreateReceiverArgs = { }; +export type MutationCreateTenantArgs = { + input: CreateTenantInput; +}; + + +export type MutationCreateTenantSettingsArgs = { + input: CreateTenantSettingsInput; +}; + + export type MutationCreateWalletAddressArgs = { input: CreateWalletAddressInput; }; @@ -868,6 +925,11 @@ export type MutationDeletePeerArgs = { }; +export type MutationDeleteTenantArgs = { + id: Scalars['String']['input']; +}; + + export type MutationDepositAssetLiquidityArgs = { input: DepositAssetLiquidityInput; }; @@ -923,6 +985,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationUpdateTenantArgs = { + input: UpdateTenantInput; +}; + + export type MutationUpdateWalletAddressArgs = { input: UpdateWalletAddressInput; }; @@ -967,6 +1034,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1097,6 +1166,8 @@ export type Peer = Model & { name?: Maybe; /** ILP address of the peer. */ staticIlpAddress: Scalars['String']['output']; + /** Unique identifier of the tenant associated with the peer. */ + tenantId: Scalars['ID']['output']; }; export type PeerEdge = { @@ -1150,6 +1221,10 @@ export type Query = { quote?: Maybe; /** Retrieve an Open Payments incoming payment by receiver ID. The receiver's wallet address can be hosted on this server or a remote Open Payments resource server. */ receiver?: Maybe; + /** Retrieve a tenant of the instance. */ + tenant: Tenant; + /** As an operator, fetch a paginated list of tenants on the instance. */ + tenants: TenantsConnection; /** Fetch a wallet address by its ID. */ walletAddress?: Maybe; /** Get a wallet address by its url if it exists */ @@ -1158,6 +1233,8 @@ export type Query = { walletAddresses: WalletAddressesConnection; /** Fetch a paginated list of webhook events. */ webhookEvents: WebhookEventsConnection; + /** Determine if the requester has operator permissions */ + whoami: WhoamiResponse; }; @@ -1184,6 +1261,7 @@ export type QueryAssetsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1204,6 +1282,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1214,6 +1293,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1234,6 +1314,7 @@ export type QueryPeersArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1247,6 +1328,20 @@ export type QueryReceiverArgs = { }; +export type QueryTenantArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryTenantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + + export type QueryWalletAddressArgs = { id: Scalars['String']['input']; }; @@ -1263,6 +1358,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1273,6 +1369,7 @@ export type QueryWebhookEventsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; export type Quote = { @@ -1291,6 +1388,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1374,6 +1473,64 @@ export enum SortOrder { Desc = 'DESC' } +export type Tenant = Model & { + __typename?: 'Tenant'; + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['output']; + /** The date and time that this tenant was created. */ + createdAt: Scalars['String']['output']; + /** The date and time that this tenant was deleted. */ + deletedAt?: Maybe; + /** Contact email of the tenant owner. */ + email?: Maybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['output']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: Maybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: Maybe; + /** Public name for the tenant. */ + publicName?: Maybe; + /** List of settings for the tenant. */ + settings: Array; +}; + +export type TenantEdge = { + __typename?: 'TenantEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant node in the list. */ + node: Tenant; +}; + +export type TenantMutationResponse = { + __typename?: 'TenantMutationResponse'; + tenant: Tenant; +}; + +export type TenantSetting = { + __typename?: 'TenantSetting'; + /** Key for this setting. */ + key: Scalars['String']['output']; + /** Value of a setting for this key. */ + value: Scalars['String']['output']; +}; + +export type TenantSettingInput = { + /** Key for this setting. */ + key: Scalars['String']['input']; + /** Value of a setting for this key. */ + value: Scalars['String']['input']; +}; + +export type TenantsConnection = { + __typename?: 'TenantsConnection'; + /** A list of edges representing tenants and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + export enum TransferState { /** The accounting transfer is pending */ Pending = 'PENDING', @@ -1446,6 +1603,21 @@ export type UpdatePeerMutationResponse = { peer?: Maybe; }; +export type UpdateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret?: InputMaybe; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['input']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; +}; + export type UpdateWalletAddressInput = { /** Additional properties associated with this wallet address. */ additionalProperties?: InputMaybe>; @@ -1476,6 +1648,8 @@ export type WalletAddress = Model & { __typename?: 'WalletAddress'; /** Additional properties associated with the wallet address. */ additionalProperties?: Maybe>>; + /** Wallet Address. */ + address: Scalars['String']['output']; /** Asset of the wallet address. */ asset: Asset; /** The date and time when the wallet address was created. */ @@ -1494,8 +1668,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; - /** Wallet Address URL. */ - url: Scalars['String']['output']; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** List of keys associated with this wallet address */ walletAddressKeys?: Maybe; }; @@ -1613,6 +1787,8 @@ export type WebhookEvent = Model & { data: Scalars['JSONObject']['output']; /** Unique identifier of the webhook event. */ id: Scalars['ID']['output']; + /** Tenant of the webhook event. */ + tenantId: Scalars['ID']['output']; /** Type of webhook event. */ type: Scalars['String']['output']; }; @@ -1638,6 +1814,12 @@ export type WebhookEventsEdge = { node: WebhookEvent; }; +export type WhoamiResponse = { + __typename?: 'WhoamiResponse'; + id: Scalars['String']['output']; + isOperator: Scalars['Boolean']['output']; +}; + export type WithdrawEventLiquidityInput = { /** Unique identifier of the event to withdraw liquidity from. */ eventId: Scalars['String']['input']; @@ -1716,7 +1898,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1754,6 +1936,9 @@ export type ResolversTypes = { CreateQuoteInput: ResolverTypeWrapper>; CreateReceiverInput: ResolverTypeWrapper>; CreateReceiverResponse: ResolverTypeWrapper>; + CreateTenantInput: ResolverTypeWrapper>; + CreateTenantSettingsInput: ResolverTypeWrapper>; + CreateTenantSettingsMutationResponse: ResolverTypeWrapper>; CreateWalletAddressInput: ResolverTypeWrapper>; CreateWalletAddressKeyInput: ResolverTypeWrapper>; CreateWalletAddressKeyMutationResponse: ResolverTypeWrapper>; @@ -1764,6 +1949,7 @@ export type ResolversTypes = { DeleteAssetMutationResponse: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DeleteTenantMutationResponse: ResolverTypeWrapper>; DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; @@ -1823,6 +2009,12 @@ export type ResolversTypes = { SetFeeResponse: ResolverTypeWrapper>; SortOrder: ResolverTypeWrapper>; String: ResolverTypeWrapper>; + Tenant: ResolverTypeWrapper>; + TenantEdge: ResolverTypeWrapper>; + TenantMutationResponse: ResolverTypeWrapper>; + TenantSetting: ResolverTypeWrapper>; + TenantSettingInput: ResolverTypeWrapper>; + TenantsConnection: ResolverTypeWrapper>; TransferState: ResolverTypeWrapper>; TransferType: ResolverTypeWrapper>; TriggerWalletAddressEventsInput: ResolverTypeWrapper>; @@ -1833,6 +2025,7 @@ export type ResolversTypes = { UpdateIncomingPaymentInput: ResolverTypeWrapper>; UpdatePeerInput: ResolverTypeWrapper>; UpdatePeerMutationResponse: ResolverTypeWrapper>; + UpdateTenantInput: ResolverTypeWrapper>; UpdateWalletAddressInput: ResolverTypeWrapper>; UpdateWalletAddressMutationResponse: ResolverTypeWrapper>; VoidLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1849,6 +2042,7 @@ export type ResolversTypes = { WebhookEventFilter: ResolverTypeWrapper>; WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; + WhoamiResponse: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; }; @@ -1886,6 +2080,9 @@ export type ResolversParentTypes = { CreateQuoteInput: Partial; CreateReceiverInput: Partial; CreateReceiverResponse: Partial; + CreateTenantInput: Partial; + CreateTenantSettingsInput: Partial; + CreateTenantSettingsMutationResponse: Partial; CreateWalletAddressInput: Partial; CreateWalletAddressKeyInput: Partial; CreateWalletAddressKeyMutationResponse: Partial; @@ -1895,6 +2092,7 @@ export type ResolversParentTypes = { DeleteAssetMutationResponse: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DeleteTenantMutationResponse: Partial; DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; DepositOutgoingPaymentLiquidityInput: Partial; @@ -1947,6 +2145,12 @@ export type ResolversParentTypes = { SetFeeInput: Partial; SetFeeResponse: Partial; String: Partial; + Tenant: Partial; + TenantEdge: Partial; + TenantMutationResponse: Partial; + TenantSetting: Partial; + TenantSettingInput: Partial; + TenantsConnection: Partial; TriggerWalletAddressEventsInput: Partial; TriggerWalletAddressEventsMutationResponse: Partial; UInt8: Partial; @@ -1955,6 +2159,7 @@ export type ResolversParentTypes = { UpdateIncomingPaymentInput: Partial; UpdatePeerInput: Partial; UpdatePeerMutationResponse: Partial; + UpdateTenantInput: Partial; UpdateWalletAddressInput: Partial; UpdateWalletAddressMutationResponse: Partial; VoidLiquidityWithdrawalInput: Partial; @@ -1970,6 +2175,7 @@ export type ResolversParentTypes = { WebhookEventFilter: Partial; WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; + WhoamiResponse: Partial; WithdrawEventLiquidityInput: Partial; }; @@ -2021,6 +2227,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2071,6 +2278,11 @@ export type CreateReceiverResponseResolvers; }; +export type CreateTenantSettingsMutationResponseResolvers = { + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2091,6 +2303,11 @@ export type DeletePeerMutationResponseResolvers; }; +export type DeleteTenantMutationResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeeResolvers = { assetId?: Resolver; basisPoints?: Resolver; @@ -2134,6 +2351,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2174,7 +2392,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2195,11 +2413,14 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; createReceiver?: Resolver>; + createTenant?: Resolver>; + createTenantSettings?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddress?: Resolver>; createWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deleteAsset?: Resolver>; deletePeer?: Resolver>; + deleteTenant?: Resolver>; depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2211,6 +2432,7 @@ export type MutationResolvers>; updateIncomingPayment?: Resolver>; updatePeer?: Resolver>; + updateTenant?: Resolver>; updateWalletAddress?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2231,6 +2453,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2294,6 +2517,7 @@ export type PeerResolvers, ParentType, ContextType>; name?: Resolver, ParentType, ContextType>; staticIlpAddress?: Resolver; + tenantId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2323,10 +2547,13 @@ export type QueryResolvers>; quote?: Resolver, ParentType, ContextType, RequireFields>; receiver?: Resolver, ParentType, ContextType, RequireFields>; + tenant?: Resolver>; + tenants?: Resolver>; walletAddress?: Resolver, ParentType, ContextType, RequireFields>; walletAddressByUrl?: Resolver, ParentType, ContextType, RequireFields>; walletAddresses?: Resolver>; webhookEvents?: Resolver>; + whoami?: Resolver; }; export type QuoteResolvers = { @@ -2337,6 +2564,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2380,6 +2608,42 @@ export type SetFeeResponseResolvers; }; +export type TenantResolvers = { + apiSecret?: Resolver; + createdAt?: Resolver; + deletedAt?: Resolver, ParentType, ContextType>; + email?: Resolver, ParentType, ContextType>; + id?: Resolver; + idpConsentUrl?: Resolver, ParentType, ContextType>; + idpSecret?: Resolver, ParentType, ContextType>; + publicName?: Resolver, ParentType, ContextType>; + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantMutationResponseResolvers = { + tenant?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingResolvers = { + key?: Resolver; + value?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type TriggerWalletAddressEventsMutationResponseResolvers = { count?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2405,6 +2669,7 @@ export type UpdateWalletAddressMutationResponseResolvers = { additionalProperties?: Resolver>>, ParentType, ContextType>; + address?: Resolver; asset?: Resolver; createdAt?: Resolver; id?: Resolver; @@ -2414,7 +2679,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; - url?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2468,6 +2733,7 @@ export type WebhookEventResolvers; data?: Resolver; id?: Resolver; + tenantId?: Resolver; type?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2484,6 +2750,12 @@ export type WebhookEventsEdgeResolvers; }; +export type WhoamiResponseResolvers = { + id?: Resolver; + isOperator?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AccountingTransfer?: AccountingTransferResolvers; AccountingTransferConnection?: AccountingTransferConnectionResolvers; @@ -2499,10 +2771,12 @@ export type Resolvers = { CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; + CreateTenantSettingsMutationResponse?: CreateTenantSettingsMutationResponseResolvers; CreateWalletAddressKeyMutationResponse?: CreateWalletAddressKeyMutationResponseResolvers; CreateWalletAddressMutationResponse?: CreateWalletAddressMutationResponseResolvers; DeleteAssetMutationResponse?: DeleteAssetMutationResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; + DeleteTenantMutationResponse?: DeleteTenantMutationResponseResolvers; Fee?: FeeResolvers; FeeEdge?: FeeEdgeResolvers; FeesConnection?: FeesConnectionResolvers; @@ -2536,6 +2810,11 @@ export type Resolvers = { Receiver?: ReceiverResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; + Tenant?: TenantResolvers; + TenantEdge?: TenantEdgeResolvers; + TenantMutationResponse?: TenantMutationResponseResolvers; + TenantSetting?: TenantSettingResolvers; + TenantsConnection?: TenantsConnectionResolvers; TriggerWalletAddressEventsMutationResponse?: TriggerWalletAddressEventsMutationResponseResolvers; UInt8?: GraphQLScalarType; UInt64?: GraphQLScalarType; @@ -2552,5 +2831,6 @@ export type Resolvers = { WebhookEvent?: WebhookEventResolvers; WebhookEventsConnection?: WebhookEventsConnectionResolvers; WebhookEventsEdge?: WebhookEventsEdgeResolvers; + WhoamiResponse?: WhoamiResponseResolvers; }; diff --git a/packages/backend/src/graphql/middleware/index.test.ts b/packages/backend/src/graphql/middleware/index.test.ts index 6456b418df..5728a0170c 100644 --- a/packages/backend/src/graphql/middleware/index.test.ts +++ b/packages/backend/src/graphql/middleware/index.test.ts @@ -33,7 +33,7 @@ describe('GraphQL Middleware', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/middleware/index.ts b/packages/backend/src/graphql/middleware/index.ts index d8e490e4c4..f2b7d0ff53 100644 --- a/packages/backend/src/graphql/middleware/index.ts +++ b/packages/backend/src/graphql/middleware/index.ts @@ -1,9 +1,14 @@ import { GraphQLError } from 'graphql' import { IMiddleware } from 'graphql-middleware' -import { ApolloContext } from '../../app' +import { + ApolloContext, + ForTenantIdContext, + TenantedApolloContext +} from '../../app' import { CacheDataStore } from '../../middleware/cache/data-stores' import { lockMiddleware, Lock } from '../../middleware/lock' import { cacheMiddleware } from '../../middleware/cache' +import { validateTenantMiddleware } from '../../middleware/tenant' export function lockGraphQLMutationMiddleware(lock: Lock): { Mutation: IMiddleware @@ -46,3 +51,17 @@ export function idempotencyGraphQLMiddleware( } } } + +export function setForTenantIdGraphQLMutationMiddleware(): { + Mutation: IMiddleware +} { + return { + Mutation: async (resolve, root, args, context, info) => { + return validateTenantMiddleware({ + deps: { context }, + next: () => resolve(root, args, context, info), + tenantIdInput: args?.input?.tenantId + }) + } + } +} diff --git a/packages/backend/src/graphql/resolvers/accounting_transfer.psql.test.ts b/packages/backend/src/graphql/resolvers/accounting_transfer.psql.test.ts index 1ec2f06a61..fea75ff56c 100644 --- a/packages/backend/src/graphql/resolvers/accounting_transfer.psql.test.ts +++ b/packages/backend/src/graphql/resolvers/accounting_transfer.psql.test.ts @@ -37,7 +37,7 @@ describe('Accounting Transfer', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/accounting_transfer.tigerbeetle.test.ts b/packages/backend/src/graphql/resolvers/accounting_transfer.tigerbeetle.test.ts index b6fe08cee7..e35a9d41ac 100644 --- a/packages/backend/src/graphql/resolvers/accounting_transfer.tigerbeetle.test.ts +++ b/packages/backend/src/graphql/resolvers/accounting_transfer.tigerbeetle.test.ts @@ -39,7 +39,7 @@ describe('TigerBeetle: Accounting Transfer', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 7968de3f98..12b6fdb2af 100644 --- a/packages/backend/src/graphql/resolvers/asset.test.ts +++ b/packages/backend/src/graphql/resolvers/asset.test.ts @@ -3,7 +3,11 @@ import assert from 'assert' import { v4 as uuid } from 'uuid' import { getPageTests } from './page.test' -import { createTestApp, TestContainer } from '../../tests/app' +import { + createApolloClient, + createTestApp, + TestContainer +} from '../../tests/app' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../../app' import { initIocContainer } from '../..' @@ -32,6 +36,7 @@ import { isFeeError } from '../../fee/errors' import { createFee } from '../../tests/fee' import { createAsset } from '../../tests/asset' import { GraphQLErrorCode } from '../errors' +import { createTenant } from '../../tests/tenant' describe('Asset Resolvers', (): void => { let deps: IocContract @@ -49,7 +54,7 @@ describe('Asset Resolvers', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -132,7 +137,7 @@ describe('Asset Resolvers', (): void => { test('Returns error for duplicate asset', async (): Promise => { const input = randomAsset() - await assetService.create(input) + await assetService.create({ ...input, tenantId: Config.operatorTenantId }) expect.assertions(2) try { @@ -212,12 +217,62 @@ describe('Asset Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant create', async (): Promise => { + const otherTenant = await createTenant(deps) + const badInputData = { + ...randomAsset(), + tenantId: uuid() + } + + const tenantedApolloClient = await createApolloClient( + appContainer.container, + appContainer.app, + otherTenant.id + ) + try { + expect.assertions(2) + await tenantedApolloClient + .mutate({ + mutation: gql` + mutation CreateAsset($input: CreateAssetInput!) { + createAsset(input: $input) { + asset { + id + } + } + } + `, + variables: { + input: badInputData + } + }) + .then((query): AssetMutationResponse => { + if (query.data) { + return query.data.createAsset + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Assignment to the specified tenant is not permitted', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) }) describe('Asset Queries', (): void => { test('Can get an asset', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -283,6 +338,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset by code and scale', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -349,7 +405,10 @@ describe('Asset Resolvers', (): void => { { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Sending }, { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Receiving } ])('Can get an asset with fee of %p', async (fee): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) let expectedFee = null @@ -469,6 +528,7 @@ describe('Asset Resolvers', (): void => { createModel: () => assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) as Promise, @@ -480,6 +540,7 @@ describe('Asset Resolvers', (): void => { for (let i = 0; i < 2; i++) { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -620,6 +681,7 @@ describe('Asset Resolvers', (): void => { beforeEach(async (): Promise => { asset = (await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold })) as AssetModel diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index 50638d721c..c45f5d91b8 100644 --- a/packages/backend/src/graphql/resolvers/asset.ts +++ b/packages/backend/src/graphql/resolvers/asset.ts @@ -7,7 +7,7 @@ import { } from '../generated/graphql' import { Asset } from '../../asset/model' import { errorToCode, errorToMessage, isAssetError } from '../../asset/errors' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { feeToGraphql } from './fee' @@ -15,37 +15,45 @@ import { Fee, FeeType } from '../../fee/model' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getAssets: QueryResolvers['assets'] = async ( - parent, - args, - ctx -): Promise => { - const assetService = await ctx.container.use('assetService') - const { sortOrder, ...pagination } = args - const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc - const assets = await assetService.getPage(pagination, order) - const pageInfo = await getPageInfo({ - getPage: (pagination: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder), - page: assets, - sortOrder: order - }) - return { - pageInfo, - edges: assets.map((asset: Asset) => ({ - cursor: asset.id, - node: assetToGraphql(asset) - })) +export const getAssets: QueryResolvers['assets'] = + async (parent, args, ctx): Promise => { + const assetService = await ctx.container.use('assetService') + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const assets = await assetService.getPage({ + pagination, + sortOrder: order, + tenantId: ctx.isOperator ? args.tenantId : ctx.tenant.id + }) + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + assetService.getPage({ + pagination, + sortOrder, + tenantId: ctx.isOperator ? args.tenantId : ctx.tenant.id + }), + page: assets, + sortOrder: order + }) + return { + pageInfo, + edges: assets.map((asset: Asset) => ({ + cursor: asset.id, + node: assetToGraphql(asset) + })) + } } -} -export const getAsset: QueryResolvers['asset'] = async ( +export const getAsset: QueryResolvers['asset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.get(args.id) + const asset = await assetService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!asset) { throw new GraphQLError('Asset not found', { extensions: { @@ -56,21 +64,38 @@ export const getAsset: QueryResolvers['asset'] = async ( return assetToGraphql(asset) } -export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = +export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = async (parent, args, ctx): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.getByCodeAndScale(args.code, args.scale) + const asset = await assetService.getByCodeAndScale({ + code: args.code, + scale: args.scale, + tenantId: ctx.tenant.id + }) return asset ? assetToGraphql(asset) : null } -export const createAsset: MutationResolvers['createAsset'] = +export const createAsset: MutationResolvers['createAsset'] = async ( parent, args, ctx ): Promise => { + const tenantId = ctx.forTenantId + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) const assetService = await ctx.container.use('assetService') - const assetOrError = await assetService.create(args.input) + const assetOrError = await assetService.create({ + ...args.input, + tenantId + }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { extensions: { @@ -83,7 +108,7 @@ export const createAsset: MutationResolvers['createAsset'] = } } -export const updateAsset: MutationResolvers['updateAsset'] = +export const updateAsset: MutationResolvers['updateAsset'] = async ( parent, args, @@ -93,7 +118,8 @@ export const updateAsset: MutationResolvers['updateAsset'] = const assetOrError = await assetService.update({ id: args.input.id, withdrawalThreshold: args.input.withdrawalThreshold ?? null, - liquidityThreshold: args.input.liquidityThreshold ?? null + liquidityThreshold: args.input.liquidityThreshold ?? null, + tenantId: ctx.tenant.id }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { @@ -107,7 +133,7 @@ export const updateAsset: MutationResolvers['updateAsset'] = } } -export const getAssetSendingFee: AssetResolvers['sendingFee'] = +export const getAssetSendingFee: AssetResolvers['sendingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -119,7 +145,7 @@ export const getAssetSendingFee: AssetResolvers['sendingFee'] = return feeToGraphql(fee) } -export const getAssetReceivingFee: AssetResolvers['receivingFee'] = +export const getAssetReceivingFee: AssetResolvers['receivingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -131,7 +157,7 @@ export const getAssetReceivingFee: AssetResolvers['receivingFee'] return feeToGraphql(fee) } -export const getFees: AssetResolvers['fees'] = async ( +export const getFees: AssetResolvers['fees'] = async ( parent, args, ctx @@ -159,7 +185,7 @@ export const getFees: AssetResolvers['fees'] = async ( } } -export const deleteAsset: MutationResolvers['deleteAsset'] = +export const deleteAsset: MutationResolvers['deleteAsset'] = async ( _, args, @@ -168,6 +194,7 @@ export const deleteAsset: MutationResolvers['deleteAsset'] = const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.delete({ id: args.input.id, + tenantId: ctx.tenant.id, deletedAt: new Date() }) @@ -189,5 +216,6 @@ export const assetToGraphql = (asset: Asset): SchemaAsset => ({ scale: asset.scale, withdrawalThreshold: asset.withdrawalThreshold, liquidityThreshold: asset.liquidityThreshold, - createdAt: new Date(+asset.createdAt).toISOString() + createdAt: new Date(+asset.createdAt).toISOString(), + tenantId: asset.tenantId }) diff --git a/packages/backend/src/graphql/resolvers/auto-peering.test.ts b/packages/backend/src/graphql/resolvers/auto-peering.test.ts index 49715fdfe0..ee13a3caa2 100644 --- a/packages/backend/src/graphql/resolvers/auto-peering.test.ts +++ b/packages/backend/src/graphql/resolvers/auto-peering.test.ts @@ -93,7 +93,7 @@ describe('Auto Peering Resolvers', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -111,7 +111,8 @@ describe('Auto Peering Resolvers', (): void => { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', name: 'Test Peer', - httpToken: 'httpToken' + httpToken: 'httpToken', + tenant: Config.operatorTenantId } const scope = nock(input.peerUrl).post('/').reply(200, peerDetails) @@ -150,7 +151,8 @@ describe('Auto Peering Resolvers', (): void => { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', name: 'Test Peer', - httpToken: 'httpToken' + httpToken: 'httpToken', + tenantId: Config.operatorTenantId } const secondPeerDetails = { diff --git a/packages/backend/src/graphql/resolvers/auto-peering.ts b/packages/backend/src/graphql/resolvers/auto-peering.ts index ebf6386aa9..f7b3139df4 100644 --- a/packages/backend/src/graphql/resolvers/auto-peering.ts +++ b/packages/backend/src/graphql/resolvers/auto-peering.ts @@ -1,6 +1,6 @@ import { ResolversTypes, MutationResolvers } from '../generated/graphql' import { Peer } from '../../payment-method/ilp/peer/model' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { AutoPeeringError, errorToCode, @@ -10,7 +10,7 @@ import { import { peerToGraphql } from './peer' import { GraphQLError } from 'graphql' -export const createOrUpdatePeerByUrl: MutationResolvers['createOrUpdatePeerByUrl'] = +export const createOrUpdatePeerByUrl: MutationResolvers['createOrUpdatePeerByUrl'] = async ( _, args, @@ -18,7 +18,10 @@ export const createOrUpdatePeerByUrl: MutationResolvers['createOr ): Promise => { const autoPeeringService = await ctx.container.use('autoPeeringService') const peerOrError: Peer | AutoPeeringError = - await autoPeeringService.initiatePeeringRequest(args.input) + await autoPeeringService.initiatePeeringRequest({ + ...args.input, + tenantId: ctx.tenant.id + }) if (isAutoPeeringError(peerOrError)) { throw new GraphQLError(errorToMessage[peerOrError], { extensions: { diff --git a/packages/backend/src/graphql/resolvers/combined_payments.test.ts b/packages/backend/src/graphql/resolvers/combined_payments.test.ts index 50af7a5d4c..8cea2d69c0 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.test.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.test.ts @@ -34,7 +34,7 @@ describe('Payment', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -50,11 +50,13 @@ describe('Payment', (): void => { test('Can get payments', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const client = 'client-test' const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId, client: client, method: 'ilp', @@ -68,11 +70,13 @@ describe('Payment', (): void => { }) const { id: inWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: inWalletAddressId, - client: client + client: client, + tenantId: Config.operatorTenantId }) const query = await appContainer.apolloClient @@ -146,6 +150,7 @@ describe('Payment', (): void => { test('Can filter payments by type and wallet address', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -161,6 +166,7 @@ describe('Payment', (): void => { const client = 'client-test-type-wallet-address' const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId, client: client, method: 'ilp', @@ -168,9 +174,11 @@ describe('Payment', (): void => { }) const { id: outWalletAddressId2 } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId2, client: client, method: 'ilp', diff --git a/packages/backend/src/graphql/resolvers/combined_payments.ts b/packages/backend/src/graphql/resolvers/combined_payments.ts index 68ced9192d..410045d5fc 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.ts @@ -3,24 +3,25 @@ import { QueryResolvers, Payment as SchemaPayment } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { CombinedPayment } from '../../open_payments/payment/combined/model' -export const getCombinedPayments: QueryResolvers['payments'] = +export const getCombinedPayments: QueryResolvers['payments'] = async (parent, args, ctx): Promise => { const combinedPaymentService = await ctx.container.use( 'combinedPaymentService' ) - const { filter, sortOrder, ...pagination } = args + const { filter, sortOrder, tenantId, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc const getPageFn = (pagination_: Pagination, sortOrder_?: SortOrder) => combinedPaymentService.getPage({ pagination: pagination_, filter, - sortOrder: sortOrder_ + sortOrder: sortOrder_, + tenantId: ctx.isOperator ? tenantId : ctx.tenant.id }) const payments = await getPageFn(pagination, order) diff --git a/packages/backend/src/graphql/resolvers/fee.test.ts b/packages/backend/src/graphql/resolvers/fee.test.ts index ee7306f2ef..8eade969c3 100644 --- a/packages/backend/src/graphql/resolvers/fee.test.ts +++ b/packages/backend/src/graphql/resolvers/fee.test.ts @@ -31,7 +31,7 @@ describe('Fee Resolvers', () => { }) afterEach(async () => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async () => { diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts index 0db5f7cf66..30cbfa893b 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts @@ -38,6 +38,7 @@ describe('Incoming Payment Resolver', (): void => { let incomingPaymentService: IncomingPaymentService let accountingService: AccountingService let asset: Asset + let tenantId: string beforeAll(async (): Promise => { deps = await initIocContainer(Config) @@ -45,18 +46,23 @@ describe('Incoming Payment Resolver', (): void => { incomingPaymentService = await deps.use('incomingPaymentService') accountingService = await deps.use('accountingService') asset = await createAsset(deps) + tenantId = Config.operatorTenantId }) afterAll(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) await appContainer.apolloClient.stop() await appContainer.shutdown() }) describe('Wallet address incoming payments', (): void => { beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps, { assetId: asset.id })) - .id + walletAddressId = ( + await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, + assetId: asset.id + }) + ).id }) getPageTests({ @@ -74,7 +80,8 @@ describe('Incoming Payment Resolver', (): void => { metadata: { description: `IncomingPayment`, externalRef: '#123' - } + }, + tenantId }), pagedQuery: 'incomingPayments', parent: { @@ -106,6 +113,7 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata, expiresAt, withAmount }): Promise => { const incomingAmount = withAmount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { @@ -113,7 +121,8 @@ describe('Incoming Payment Resolver', (): void => { client, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const createSpy = jest @@ -162,7 +171,7 @@ describe('Incoming Payment Resolver', (): void => { query.data?.createIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query).toEqual({ __typename: 'IncomingPaymentResponse', payment: { @@ -232,7 +241,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -277,7 +286,10 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId + }) }) }) @@ -294,7 +306,8 @@ describe('Incoming Payment Resolver', (): void => { value: BigInt(56), assetCode: asset.code, assetScale: asset.scale - } + }, + tenantId }) } @@ -305,6 +318,7 @@ describe('Incoming Payment Resolver', (): void => { } beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) payment = await createPayment({ walletAddressId, metadata }) @@ -462,13 +476,15 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata }): Promise => { const incomingAmount = amount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { walletAddressId, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const input = { id: payment.id, @@ -503,7 +519,7 @@ describe('Incoming Payment Resolver', (): void => { query.data?.updateIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query).toEqual({ __typename: 'IncomingPaymentResponse', payment: { @@ -558,7 +574,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -604,7 +620,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) }) diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.ts b/packages/backend/src/graphql/resolvers/incoming_payment.ts index 778654dc2e..eb77bc7691 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.ts @@ -11,19 +11,20 @@ import { errorToCode, errorToMessage } from '../../open_payments/payment/incoming/errors' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getIncomingPayment: QueryResolvers['incomingPayment'] = +export const getIncomingPayment: QueryResolvers['incomingPayment'] = async (parent, args, ctx): Promise => { const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) const payment = await incomingPaymentService.get({ - id: args.id + id: args.id, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) if (!payment) { throw new GraphQLError('payment does not exist', { @@ -35,7 +36,7 @@ export const getIncomingPayment: QueryResolvers['incomingPayment' return paymentToGraphql(payment) } -export const getWalletAddressIncomingPayments: WalletAddressResolvers['incomingPayments'] = +export const getWalletAddressIncomingPayments: WalletAddressResolvers['incomingPayments'] = async ( parent, args, @@ -56,14 +57,16 @@ export const getWalletAddressIncomingPayments: WalletAddressResolvers incomingPaymentService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId: ctx.tenant.id }), page: incomingPayments, sortOrder: order @@ -79,7 +82,7 @@ export const getWalletAddressIncomingPayments: WalletAddressResolvers['createIncomingPayment'] = +export const createIncomingPayment: MutationResolvers['createIncomingPayment'] = async ( parent, args, @@ -88,13 +91,20 @@ export const createIncomingPayment: MutationResolvers['createInco const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) + + const tenantId = ctx.forTenantId + if (!tenantId) { + throw new Error('Missing tenant id to create incoming payment') + } + const incomingPaymentOrError = await incomingPaymentService.create({ walletAddressId: args.input.walletAddressId, expiresAt: !args.input.expiresAt ? undefined : new Date(args.input.expiresAt), incomingAmount: args.input.incomingAmount, - metadata: args.input.metadata + metadata: args.input.metadata, + tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw new GraphQLError(errorToMessage[incomingPaymentOrError], { @@ -108,7 +118,7 @@ export const createIncomingPayment: MutationResolvers['createInco } } -export const updateIncomingPayment: MutationResolvers['updateIncomingPayment'] = +export const updateIncomingPayment: MutationResolvers['updateIncomingPayment'] = async ( parent, args, @@ -117,9 +127,10 @@ export const updateIncomingPayment: MutationResolvers['updateInco const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) - const incomingPaymentOrError = await incomingPaymentService.update( - args.input - ) + const incomingPaymentOrError = await incomingPaymentService.update({ + ...args.input, + tenantId: ctx.tenant.id + }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw new GraphQLError(errorToMessage[incomingPaymentOrError], { extensions: { @@ -132,7 +143,7 @@ export const updateIncomingPayment: MutationResolvers['updateInco } } -export const approveIncomingPayment: MutationResolvers['approveIncomingPayment'] = +export const approveIncomingPayment: MutationResolvers['approveIncomingPayment'] = async ( parent, args, @@ -143,7 +154,8 @@ export const approveIncomingPayment: MutationResolvers['approveIn ) const incomingPaymentOrError = await incomingPaymentService.approve( - args.input.id + args.input.id, + ctx.tenant.id ) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -159,7 +171,7 @@ export const approveIncomingPayment: MutationResolvers['approveIn } } -export const cancelIncomingPayment: MutationResolvers['cancelIncomingPayment'] = +export const cancelIncomingPayment: MutationResolvers['cancelIncomingPayment'] = async ( parent, args, @@ -170,7 +182,8 @@ export const cancelIncomingPayment: MutationResolvers['cancelInco ) const incomingPaymentOrError = await incomingPaymentService.cancel( - args.input.id + args.input.id, + ctx.tenant.id ) if (isIncomingPaymentError(incomingPaymentOrError)) { diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 2c191b30e8..303b10dbee 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -77,6 +77,15 @@ import { GraphQLJSONObject } from 'graphql-scalars' import { getCombinedPayments } from './combined_payments' import { createOrUpdatePeerByUrl } from './auto-peering' import { getAccountingTransfers } from './accounting_transfer' +import { + whoami, + createTenant, + updateTenant, + deleteTenant, + getTenant, + getTenants +} from './tenant' +import { createTenantSettings, getTenantSettings } from './tenant_settings' export const resolvers: Resolvers = { UInt8: GraphQLUInt8, @@ -92,6 +101,7 @@ export const resolvers: Resolvers = { liquidity: getPeerLiquidity }, Query: { + whoami, walletAddress: getWalletAddress, walletAddressByUrl: getWalletAddressByUrl, walletAddresses: getWalletAddresses, @@ -108,7 +118,9 @@ export const resolvers: Resolvers = { webhookEvents: getWebhookEvents, payments: getCombinedPayments, accountingTransfers: getAccountingTransfers, - receiver: getReceiver + receiver: getReceiver, + tenant: getTenant, + tenants: getTenants }, WalletAddress: { liquidity: getWalletAddressLiquidity, @@ -118,6 +130,9 @@ export const resolvers: Resolvers = { walletAddressKeys: getWalletAddressKeys, additionalProperties: getWalletAddressAdditionalProperties }, + Tenant: { + settings: getTenantSettings + }, IncomingPayment: { liquidity: getIncomingPaymentLiquidity }, @@ -161,6 +176,10 @@ export const resolvers: Resolvers = { createIncomingPaymentWithdrawal, createOutgoingPaymentWithdrawal, setFee, - updateIncomingPayment + updateIncomingPayment, + createTenant, + updateTenant, + deleteTenant, + createTenantSettings } } diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index e0968b27e1..f1736afa8a 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -38,7 +38,7 @@ import { createOutgoingPayment } from '../../tests/outgoingPayment' import { createWalletAddress } from '../../tests/walletAddress' import { createPeer } from '../../tests/peer' import { truncateTables } from '../../tests/tableManager' -import { WebhookEvent } from '../../webhook/model' +import { WebhookEvent } from '../../webhook/event/model' import { LiquidityMutationResponse, WalletAddressWithdrawalMutationResponse @@ -60,7 +60,7 @@ describe('Liquidity Resolvers', (): void => { }) afterAll(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) await appContainer.apolloClient.stop() await appContainer.shutdown() }) @@ -1015,6 +1015,7 @@ describe('Liquidity Resolvers', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) @@ -1742,12 +1743,16 @@ describe('Liquidity Resolvers', (): void => { ) describe('Event Liquidity', (): void => { + let tenantId: string let walletAddress: WalletAddress let incomingPayment: IncomingPayment let payment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + tenantId = Config.operatorTenantId + walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, @@ -1756,9 +1761,11 @@ describe('Liquidity Resolvers', (): void => { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale }, - expiresAt: new Date(Date.now() + 60 * 1000) + expiresAt: new Date(Date.now() + 60 * 1000), + tenantId: Config.operatorTenantId }) payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, method: 'ilp', receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, @@ -1789,7 +1796,8 @@ describe('Liquidity Resolvers', (): void => { data: payment.toData({ amountSent: BigInt(0), balance: BigInt(0) - }) + }), + tenantId: Config.operatorTenantId }) }) @@ -1949,6 +1957,7 @@ describe('Liquidity Resolvers', (): void => { incomingPaymentId?: string outgoingPaymentId?: string walletAddressId?: string + tenantId?: string } const isIncomingPaymentEventType = ( @@ -2004,7 +2013,8 @@ describe('Liquidity Resolvers', (): void => { accountId: liquidityAccount.id, assetId: liquidityAccount.asset.id, amount - } + }, + tenantId: Config.operatorTenantId } if (resourceId) { @@ -2157,7 +2167,9 @@ describe('Liquidity Resolvers', (): void => { let outgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, @@ -2166,9 +2178,11 @@ describe('Liquidity Resolvers', (): void => { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale }, - expiresAt: new Date(Date.now() + 60 * 1000) + expiresAt: new Date(Date.now() + 60 * 1000), + tenantId: Config.operatorTenantId }) outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId, method: 'ilp', receiver: `${ @@ -2219,7 +2233,8 @@ describe('Liquidity Resolvers', (): void => { accountId: incomingPayment.id, assetId: incomingPayment.asset.id, amount - } + }, + tenantId: Config.operatorTenantId }) const response = await appContainer.apolloClient @@ -2267,7 +2282,8 @@ describe('Liquidity Resolvers', (): void => { accountId: incomingPayment.id, assetId: incomingPayment.asset.id, amount - } + }, + tenantId: Config.operatorTenantId }) let error try { @@ -2365,7 +2381,8 @@ describe('Liquidity Resolvers', (): void => { accountId: incomingPayment.id, assetId: incomingPayment.asset.id, amount - } + }, + tenantId: Config.operatorTenantId }) await expect( accountingService.createWithdrawal({ @@ -2452,7 +2469,8 @@ describe('Liquidity Resolvers', (): void => { accountId: outgoingPayment.id, assetId: outgoingPayment.asset.id, amount - } + }, + tenantId: Config.operatorTenantId }) const response = await appContainer.apolloClient @@ -2593,7 +2611,8 @@ describe('Liquidity Resolvers', (): void => { accountId: outgoingPayment.id, assetId: outgoingPayment.asset.id, amount - } + }, + tenantId: Config.operatorTenantId }) await expect( accountingService.createWithdrawal({ @@ -2662,7 +2681,8 @@ describe('Liquidity Resolvers', (): void => { data: outgoingPayment.toData({ amountSent: BigInt(0), balance: BigInt(0) - }) + }), + tenantId: Config.operatorTenantId }) }) diff --git a/packages/backend/src/graphql/resolvers/liquidity.ts b/packages/backend/src/graphql/resolvers/liquidity.ts index e402a8f8ac..a9208626e3 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.ts @@ -12,7 +12,7 @@ import { OutgoingPaymentResolvers, PaymentResolvers } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ApolloContext, TenantedApolloContext } from '../../app' import { fundingErrorToMessage, fundingErrorToCode, @@ -83,7 +83,7 @@ const getPeerOrAssetLiquidity = async ( return liquidity } -export const depositPeerLiquidity: MutationResolvers['depositPeerLiquidity'] = +export const depositPeerLiquidity: MutationResolvers['depositPeerLiquidity'] = async ( parent, args, @@ -100,7 +100,8 @@ export const depositPeerLiquidity: MutationResolvers['depositPeer const peerOrError = await peerService.depositLiquidity({ transferId: args.input.id, peerId: args.input.peerId, - amount: args.input.amount + amount: args.input.amount, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) if (peerOrError === PeerError.UnknownPeer) { @@ -162,7 +163,7 @@ export const depositAssetLiquidity: MutationResolvers['depositAss } } -export const createPeerLiquidityWithdrawal: MutationResolvers['createPeerLiquidityWithdrawal'] = +export const createPeerLiquidityWithdrawal: MutationResolvers['createPeerLiquidityWithdrawal'] = async ( parent, args, @@ -177,7 +178,10 @@ export const createPeerLiquidityWithdrawal: MutationResolvers['cr }) } const peerService = await ctx.container.use('peerService') - const peer = await peerService.get(peerId) + const peer = await peerService.get( + peerId, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!peer) { throw new GraphQLError(errorToMessage[LiquidityError.UnknownPeer], { extensions: { @@ -350,7 +354,7 @@ export type DepositEventType = OutgoingPaymentDepositType const isDepositEventType = (o: any): o is DepositEventType => Object.values(DepositEventType).includes(o) -export const depositEventLiquidity: MutationResolvers['depositEventLiquidity'] = +export const depositEventLiquidity: MutationResolvers['depositEventLiquidity'] = async ( parent, args, @@ -377,6 +381,7 @@ export const depositEventLiquidity: MutationResolvers['depositEve ) const paymentOrErr = await outgoingPaymentService.fund({ id: event.data.id, + tenantId: ctx.tenant.id, amount: BigInt(event.data.debitAmount.value), transferId: event.id }) @@ -434,7 +439,7 @@ export const withdrawEventLiquidity: MutationResolvers['withdrawE } } -export const depositOutgoingPaymentLiquidity: MutationResolvers['depositOutgoingPaymentLiquidity'] = +export const depositOutgoingPaymentLiquidity: MutationResolvers['depositOutgoingPaymentLiquidity'] = async ( parent, args, @@ -478,6 +483,7 @@ export const depositOutgoingPaymentLiquidity: MutationResolvers[' }) const paymentOrErr = await outgoingPaymentService.fund({ id: outgoingPaymentId, + tenantId: ctx.tenant.id, amount: BigInt(event.data.debitAmount.value), transferId: event.id }) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts index c1fa86f56d..3d873333a2 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts @@ -60,7 +60,7 @@ describe('OutgoingPayment Resolvers', (): void => { afterEach(async (): Promise => { jest.restoreAllMocks() - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -70,6 +70,7 @@ describe('OutgoingPayment Resolvers', (): void => { const createPayment = async ( options: { + tenantId: string walletAddressId: string metadata?: Record }, @@ -96,11 +97,14 @@ describe('OutgoingPayment Resolvers', (): void => { describe('Query.outgoingPayment', (): void => { let payment: OutgoingPaymentModel + let tenantId: string let walletAddressId: string beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddressId = ( await createWalletAddress(deps, { + tenantId, assetId: asset.id }) ).id @@ -110,6 +114,7 @@ describe('OutgoingPayment Resolvers', (): void => { getClient: () => appContainer.apolloClient, createModel: () => createPayment({ + tenantId, walletAddressId }), pagedQuery: 'outgoingPayments' @@ -137,13 +142,16 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const firstReceiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const secondWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const secondReceiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) @@ -158,10 +166,12 @@ describe('OutgoingPayment Resolvers', (): void => { } const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: firstReceiverWalletAddress.id + walletAddressId: firstReceiverWalletAddress.id, + tenantId: Config.operatorTenantId }) receiver = incomingPayment.getUrl(config.openPaymentsUrl) firstOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, receiver, method: 'ilp', @@ -169,12 +179,14 @@ describe('OutgoingPayment Resolvers', (): void => { }) const secondIncomingPayment = await createIncomingPayment(deps, { - walletAddressId: secondReceiverWalletAddress.id + walletAddressId: secondReceiverWalletAddress.id, + tenantId: Config.operatorTenantId }) const secondReceiver = secondIncomingPayment.getUrl( config.openPaymentsUrl ) secondOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: secondWalletAddress.id, receiver: secondReceiver, method: 'ilp', @@ -330,10 +342,14 @@ describe('OutgoingPayment Resolvers', (): void => { const grantId = uuid() const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId }, grantId) + const payment = await createPayment( + { tenantId, walletAddressId }, + grantId + ) const query = await appContainer.apolloClient .query({ @@ -369,9 +385,10 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - payment = await createPayment({ walletAddressId, metadata }) + payment = await createPayment({ tenantId, walletAddressId, metadata }) }) // Query with each payment state with and without an error @@ -550,16 +567,26 @@ describe('OutgoingPayment Resolvers', (): void => { }) describe('Mutation.createOutgoingPayment', (): void => { + let tenantId: string const metadata = { description: 'rent', externalRef: '202201' } + beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId + }) + test('success (metadata)', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId, metadata }) + const payment = await createPayment({ + tenantId, + walletAddressId, + metadata + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -590,7 +617,7 @@ describe('OutgoingPayment Resolvers', (): void => { (query): OutgoingPaymentResponse => query.data?.createOutgoingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query.payment?.id).toBe(payment.id) expect(query.payment?.state).toBe(SchemaPaymentState.Funding) }) @@ -638,7 +665,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('internal server error', async (): Promise => { @@ -684,18 +711,27 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) describe('Mutation.createOutgoingPaymentFromIncomingPayment', (): void => { + let tenantId: string const mockIncomingPaymentUrl = `https://${faker.internet.domainName()}/incoming-payments/${uuid()}` + beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId + }) + test('create', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId: walletAddress.id }) + const payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -732,7 +768,7 @@ describe('OutgoingPayment Resolvers', (): void => { query.data?.createOutgoingPaymentFromIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query.payment?.id).toBe(payment.id) expect(query.payment?.state).toBe(SchemaPaymentState.Funding) }) @@ -785,7 +821,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('unknown error', async (): Promise => { @@ -836,18 +872,23 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) describe('Mutation.cancelOutgoingPayment', (): void => { let payment: OutgoingPaymentModel beforeEach(async () => { + const tenantId = Config.operatorTenantId const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - payment = await createPayment({ walletAddressId: walletAddress.id }) + payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) }) const reasons: (string | undefined)[] = [undefined, 'Not enough balance'] @@ -890,7 +931,10 @@ describe('OutgoingPayment Resolvers', (): void => { query.data?.cancelOutgoingPayment ) - expect(cancelSpy).toHaveBeenCalledWith(input) + expect(cancelSpy).toHaveBeenCalledWith({ + ...input, + tenantId: payment.quote.tenantId + }) expect(mutationResponse.payment).toEqual({ __typename: 'OutgoingPayment', id: input.id, @@ -949,17 +993,23 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(cancelSpy).toHaveBeenCalledWith(input) + expect(cancelSpy).toHaveBeenCalledWith({ + ...input, + tenantId: payment.quote.tenantId + }) } ) }) describe('Wallet address outgoingPayments', (): void => { let walletAddressId: string + let tenantId: string beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddressId = ( await createWalletAddress(deps, { + tenantId, assetId: asset.id }) ).id @@ -969,6 +1019,7 @@ describe('OutgoingPayment Resolvers', (): void => { getClient: () => appContainer.apolloClient, createModel: () => createPayment({ + tenantId, walletAddressId }), pagedQuery: 'outgoingPayments', diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index a9cdcb4403..7eb85b86ee 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -12,19 +12,20 @@ import { errorToCode } from '../../open_payments/payment/outgoing/errors' import { OutgoingPayment } from '../../open_payments/payment/outgoing/model' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getOutgoingPayment: QueryResolvers['outgoingPayment'] = +export const getOutgoingPayment: QueryResolvers['outgoingPayment'] = async (parent, args, ctx): Promise => { const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) const payment = await outgoingPaymentService.get({ - id: args.id + id: args.id, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) if (!payment) { throw new GraphQLError('payment does not exist', { @@ -36,7 +37,7 @@ export const getOutgoingPayment: QueryResolvers['outgoingPayment' return paymentToGraphql(payment) } -export const getOutgoingPayments: QueryResolvers['outgoingPayments'] = +export const getOutgoingPayments: QueryResolvers['outgoingPayments'] = async ( parent, args, @@ -45,10 +46,11 @@ export const getOutgoingPayments: QueryResolvers['outgoingPayment const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) - const { filter, sortOrder, ...pagination } = args + const { tenantId, filter, sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc const getPageFn = (pagination_: Pagination, sortOrder_?: SortOrder) => outgoingPaymentService.getPage({ + tenantId: ctx.isOperator ? tenantId : ctx.tenant.id, pagination: pagination_, filter, sortOrder: sortOrder_ @@ -71,7 +73,7 @@ export const getOutgoingPayments: QueryResolvers['outgoingPayment } } -export const cancelOutgoingPayment: MutationResolvers['cancelOutgoingPayment'] = +export const cancelOutgoingPayment: MutationResolvers['cancelOutgoingPayment'] = async ( parent, args, @@ -81,9 +83,21 @@ export const cancelOutgoingPayment: MutationResolvers['cancelOutg 'outgoingPaymentService' ) - const outgoingPaymentOrError = await outgoingPaymentService.cancel( - args.input - ) + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + + const outgoingPaymentOrError = await outgoingPaymentService.cancel({ + tenantId, + ...args.input + }) if (isOutgoingPaymentError(outgoingPaymentOrError)) { throw new GraphQLError(errorToMessage[outgoingPaymentOrError], { @@ -98,7 +112,7 @@ export const cancelOutgoingPayment: MutationResolvers['cancelOutg } } -export const createOutgoingPayment: MutationResolvers['createOutgoingPayment'] = +export const createOutgoingPayment: MutationResolvers['createOutgoingPayment'] = async ( parent, args, @@ -107,9 +121,21 @@ export const createOutgoingPayment: MutationResolvers['createOutg const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) - const outgoingPaymentOrError = await outgoingPaymentService.create( - args.input - ) + + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + const outgoingPaymentOrError = await outgoingPaymentService.create({ + tenantId, + ...args.input + }) if (isOutgoingPaymentError(outgoingPaymentOrError)) { throw new GraphQLError(errorToMessage[outgoingPaymentOrError], { extensions: { @@ -122,7 +148,7 @@ export const createOutgoingPayment: MutationResolvers['createOutg } } -export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['createOutgoingPaymentFromIncomingPayment'] = +export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['createOutgoingPaymentFromIncomingPayment'] = async ( parent, args, @@ -131,10 +157,20 @@ export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['outgoingPayments'] = +export const getWalletAddressOutgoingPayments: WalletAddressResolvers['outgoingPayments'] = async ( parent, args, @@ -167,17 +203,20 @@ export const getWalletAddressOutgoingPayments: WalletAddressResolvers outgoingPaymentService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId }), page: outgoingPayments, sortOrder: order @@ -208,6 +247,7 @@ export function paymentToGraphql( metadata: payment.metadata, createdAt: new Date(+payment.createdAt).toISOString(), quote: quoteToGraphql(payment.quote), - grantId: payment.grantId + grantId: payment.grantId, + tenantId: payment.tenantId } } diff --git a/packages/backend/src/graphql/resolvers/peer.test.ts b/packages/backend/src/graphql/resolvers/peer.test.ts index 4aec23a2e8..4794c9b57b 100644 --- a/packages/backend/src/graphql/resolvers/peer.test.ts +++ b/packages/backend/src/graphql/resolvers/peer.test.ts @@ -68,7 +68,7 @@ describe('Peer Resolvers', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -140,7 +140,9 @@ describe('Peer Resolvers', (): void => { liquidityThreshold: peer.liquidityThreshold?.toString() }) delete peer.http.incoming - await expect(peerService.get(response.peer.id)).resolves.toMatchObject({ + await expect( + peerService.get(response.peer.id, Config.operatorTenantId) + ).resolves.toMatchObject({ asset, http: peer.http, maxPacketAmount: peer.maxPacketAmount, @@ -624,7 +626,9 @@ describe('Peer Resolvers', (): void => { name: updateOptions.name, liquidityThreshold: '200' }) - await expect(peerService.get(peer.id)).resolves.toMatchObject({ + await expect( + peerService.get(peer.id, peer.tenantId) + ).resolves.toMatchObject({ asset: peer.asset, http: { outgoing: updateOptions.http.outgoing @@ -771,7 +775,9 @@ describe('Peer Resolvers', (): void => { }) expect(response.success).toBe(true) - await expect(peerService.get(peer.id)).resolves.toBeUndefined() + await expect( + peerService.get(peer.id, peer.tenantId) + ).resolves.toBeUndefined() }) test('Returns error for unknown peer', async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/peer.ts b/packages/backend/src/graphql/resolvers/peer.ts index d14b73603d..89862759d3 100644 --- a/packages/backend/src/graphql/resolvers/peer.ts +++ b/packages/backend/src/graphql/resolvers/peer.ts @@ -12,23 +12,27 @@ import { errorToMessage, PeerError } from '../../payment-method/ilp/peer/errors' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' -export const getPeers: QueryResolvers['peers'] = async ( +export const getPeers: QueryResolvers['peers'] = async ( parent, args, ctx ): Promise => { const peerService = await ctx.container.use('peerService') const { sortOrder, ...pagination } = args + const tenantId = ctx.isOperator ? args.tenantId : ctx.tenant.id const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc - const peers = await peerService.getPage(pagination, order) + const peers = await peerService.getPage(pagination, order, tenantId) const pageInfo = await getPageInfo({ - getPage: (pagination: Pagination, sortOrder?: SortOrder) => - peerService.getPage(pagination, sortOrder), + getPage: ( + pagination: Pagination, + sortOrder?: SortOrder, + tenantId?: string + ) => peerService.getPage(pagination, sortOrder, tenantId), page: peers, sortOrder: order }) @@ -41,13 +45,16 @@ export const getPeers: QueryResolvers['peers'] = async ( } } -export const getPeer: QueryResolvers['peer'] = async ( +export const getPeer: QueryResolvers['peer'] = async ( parent, args, ctx ): Promise => { const peerService = await ctx.container.use('peerService') - const peer = await peerService.get(args.id) + const peer = await peerService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!peer) { throw new GraphQLError(errorToMessage[PeerError.UnknownPeer], { extensions: { @@ -58,24 +65,28 @@ export const getPeer: QueryResolvers['peer'] = async ( return peerToGraphql(peer) } -export const getPeerByAddressAndAsset: QueryResolvers['peerByAddressAndAsset'] = +export const getPeerByAddressAndAsset: QueryResolvers['peerByAddressAndAsset'] = async (parent, args, ctx): Promise => { const peerService = await ctx.container.use('peerService') const peer = await peerService.getByDestinationAddress( args.staticIlpAddress, + ctx.tenant.id, args.assetId ) return peer ? peerToGraphql(peer) : null } -export const createPeer: MutationResolvers['createPeer'] = +export const createPeer: MutationResolvers['createPeer'] = async ( parent, args, ctx ): Promise => { const peerService = await ctx.container.use('peerService') - const peerOrError = await peerService.create(args.input) + const peerOrError = await peerService.create({ + ...args.input, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }) if (isPeerError(peerOrError)) { throw new GraphQLError(errorToMessage[peerOrError], { extensions: { @@ -88,14 +99,17 @@ export const createPeer: MutationResolvers['createPeer'] = } } -export const updatePeer: MutationResolvers['updatePeer'] = +export const updatePeer: MutationResolvers['updatePeer'] = async ( parent, args, ctx ): Promise => { const peerService = await ctx.container.use('peerService') - const peerOrError = await peerService.update(args.input) + const peerOrError = await peerService.update({ + ...args.input, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }) if (isPeerError(peerOrError)) { throw new GraphQLError(errorToMessage[peerOrError], { extensions: { @@ -108,14 +122,14 @@ export const updatePeer: MutationResolvers['updatePeer'] = } } -export const deletePeer: MutationResolvers['deletePeer'] = +export const deletePeer: MutationResolvers['deletePeer'] = async ( _, args, ctx ): Promise => { const peerService = await ctx.container.use('peerService') - const peer = await peerService.delete(args.input.id) + const peer = await peerService.delete(args.input.id, ctx.tenant.id) if (!peer) { throw new GraphQLError(errorToMessage[PeerError.UnknownPeer], { extensions: { @@ -136,5 +150,6 @@ export const peerToGraphql = (peer: Peer): SchemaPeer => ({ staticIlpAddress: peer.staticIlpAddress, name: peer.name, liquidityThreshold: peer.liquidityThreshold, - createdAt: new Date(+peer.createdAt).toISOString() + createdAt: new Date(+peer.createdAt).toISOString(), + tenantId: peer.tenantId }) diff --git a/packages/backend/src/graphql/resolvers/quote.test.ts b/packages/backend/src/graphql/resolvers/quote.test.ts index ccdc5cbb47..bafb428ace 100644 --- a/packages/backend/src/graphql/resolvers/quote.test.ts +++ b/packages/backend/src/graphql/resolvers/quote.test.ts @@ -28,6 +28,7 @@ describe('Quote Resolvers', (): void => { let appContainer: TestContainer let quoteService: QuoteService let asset: Asset + let tenantId: string const receivingWalletAddress = 'http://wallet2.example/bob' const receiver = `${receivingWalletAddress}/incoming-payments/${uuid()}` @@ -39,12 +40,13 @@ describe('Quote Resolvers', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId asset = await createAsset(deps) }) afterEach(async (): Promise => { jest.restoreAllMocks() - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -56,6 +58,7 @@ describe('Quote Resolvers', (): void => { walletAddressId: string ): Promise => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount: { @@ -71,6 +74,7 @@ describe('Quote Resolvers', (): void => { describe('Query.quote', (): void => { test('success', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const quote = await createWalletAddressQuote(walletAddressId) @@ -139,7 +143,9 @@ describe('Quote Resolvers', (): void => { } } `, - variables: { quoteId: uuid() } + variables: { + quoteId: uuid() + } }) } catch (error) { expect(error).toBeInstanceOf(ApolloError) @@ -189,6 +195,7 @@ describe('Quote Resolvers', (): void => { `('$type', async ({ withAmount, receiveAmount }): Promise => { const amount = withAmount ? debitAmount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const input = { @@ -204,6 +211,7 @@ describe('Quote Resolvers', (): void => { .mockImplementationOnce(async (opts) => { quote = await createQuote(deps, { ...opts, + tenantId, validDestination: false }) return quote @@ -224,7 +232,11 @@ describe('Quote Resolvers', (): void => { }) .then((query): QuoteResponse => query.data?.createQuote) - expect(createSpy).toHaveBeenCalledWith({ ...input, method: 'ilp' }) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId, + method: 'ilp' + }) expect(query.quote?.id).toBe(quote?.id) }) @@ -290,7 +302,11 @@ describe('Quote Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith({ ...input, method: 'ilp' }) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId, + method: 'ilp' + }) }) }) @@ -300,6 +316,7 @@ describe('Quote Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { + tenantId, assetId: asset.id }) ).id diff --git a/packages/backend/src/graphql/resolvers/quote.ts b/packages/backend/src/graphql/resolvers/quote.ts index 38fa10916d..22221787f3 100644 --- a/packages/backend/src/graphql/resolvers/quote.ts +++ b/packages/backend/src/graphql/resolvers/quote.ts @@ -11,21 +11,22 @@ import { errorToMessage } from '../../open_payments/quote/errors' import { Quote } from '../../open_payments/quote/model' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { CreateQuoteOptions } from '../../open_payments/quote/service' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getQuote: QueryResolvers['quote'] = async ( +export const getQuote: QueryResolvers['quote'] = async ( parent, args, ctx ): Promise => { const quoteService = await ctx.container.use('quoteService') const quote = await quoteService.get({ - id: args.id + id: args.id, + tenantId: ctx.tenant.id }) if (!quote) { throw new GraphQLError('quote does not exist', { @@ -37,10 +38,21 @@ export const getQuote: QueryResolvers['quote'] = async ( return quoteToGraphql(quote) } -export const createQuote: MutationResolvers['createQuote'] = +export const createQuote: MutationResolvers['createQuote'] = async (parent, args, ctx): Promise => { const quoteService = await ctx.container.use('quoteService') + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) const options: CreateQuoteOptions = { + tenantId, walletAddressId: args.input.walletAddressId, receiver: args.input.receiver, method: 'ilp' @@ -62,7 +74,7 @@ export const createQuote: MutationResolvers['createQuote'] = } } -export const getWalletAddressQuotes: WalletAddressResolvers['quotes'] = +export const getWalletAddressQuotes: WalletAddressResolvers['quotes'] = async (parent, args, ctx): Promise => { if (!parent.id) { throw new GraphQLError('missing wallet address id', { @@ -74,17 +86,20 @@ export const getWalletAddressQuotes: WalletAddressResolvers['quot const quoteService = await ctx.container.use('quoteService') const { sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const tenantId = ctx.isOperator ? undefined : ctx.tenant.id const quotes = await quoteService.getWalletAddressPage({ walletAddressId: parent.id, pagination, - sortOrder: order + sortOrder: order, + tenantId }) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => quoteService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId }), page: quotes, sortOrder: order @@ -101,6 +116,7 @@ export const getWalletAddressQuotes: WalletAddressResolvers['quot export function quoteToGraphql(quote: Quote): SchemaQuote { return { id: quote.id, + tenantId: quote.tenantId, walletAddressId: quote.walletAddressId, receiver: quote.receiver, debitAmount: quote.debitAmount, diff --git a/packages/backend/src/graphql/resolvers/receiver.test.ts b/packages/backend/src/graphql/resolvers/receiver.test.ts index 1085a27b3e..c1b9ac8552 100644 --- a/packages/backend/src/graphql/resolvers/receiver.test.ts +++ b/packages/backend/src/graphql/resolvers/receiver.test.ts @@ -105,7 +105,10 @@ describe('Receiver Resolver', (): void => { }) .then((query): CreateReceiverResponse => query.data?.createReceiver) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) expect(query).toEqual({ __typename: 'CreateReceiverResponse', receiver: { @@ -184,7 +187,10 @@ describe('Receiver Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) }) test('returns error if error thrown when creating receiver', async (): Promise => { @@ -240,7 +246,10 @@ describe('Receiver Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) }) }) diff --git a/packages/backend/src/graphql/resolvers/receiver.ts b/packages/backend/src/graphql/resolvers/receiver.ts index 59db442a25..605a9bb374 100644 --- a/packages/backend/src/graphql/resolvers/receiver.ts +++ b/packages/backend/src/graphql/resolvers/receiver.ts @@ -4,7 +4,7 @@ import { Receiver as SchemaReceiver, QueryResolvers } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ApolloContext, TenantedApolloContext } from '../../app' import { Receiver } from '../../open_payments/receiver/model' import { isReceiverError, @@ -32,17 +32,23 @@ export const getReceiver: QueryResolvers['receiver'] = async ( return receiverToGraphql(receiver) } -export const createReceiver: MutationResolvers['createReceiver'] = +export const createReceiver: MutationResolvers['createReceiver'] = async (_, args, ctx): Promise => { const receiverService = await ctx.container.use('receiverService') + const tenantId = ctx.tenant.id + if (!tenantId) { + throw new Error('Tenant id is required to create a receiver') + } + const receiverOrError = await receiverService.create({ walletAddressUrl: args.input.walletAddressUrl, expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : undefined, incomingAmount: args.input.incomingAmount, - metadata: args.input.metadata + metadata: args.input.metadata, + tenantId }) if (isReceiverError(receiverOrError)) { diff --git a/packages/backend/src/graphql/resolvers/tenant.test.ts b/packages/backend/src/graphql/resolvers/tenant.test.ts new file mode 100644 index 0000000000..d291cfdbb3 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant.test.ts @@ -0,0 +1,611 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createTestApp, TestContainer } from '../../tests/app' +import { + DeleteTenantMutationResponse, + Tenant, + TenantMutationResponse, + TenantsConnection, + WhoamiResponse +} from '../generated/graphql' +import { initIocContainer } from '../..' +import { Config, IAppConfig } from '../../config/app' +import { + createTenant, + createTenantedApolloClient, + generateTenantInput +} from '../../tests/tenant' +import { ApolloError, gql, NormalizedCacheObject } from '@apollo/client' +import { getPageTests } from './page.test' +import { truncateTables } from '../../tests/tableManager' +import { ApolloClient } from '@apollo/client' +import { GraphQLErrorCode } from '../errors' +import { Tenant as TenantModel } from '../../tenants/model' +import { v4 } from 'uuid' +import { errorToMessage, TenantError } from '../../tenants/errors' + +describe('Tenant Resolvers', (): void => { + let deps: IocContract + let appContainer: TestContainer + let config: IAppConfig + const dbSchema = 'tenant_resolver_test_schema' + + beforeAll(async (): Promise => { + deps = await initIocContainer({ + ...Config, + dbSchema + }) + config = await deps.use('config') + appContainer = await createTestApp(deps) + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + }) + + afterEach(async (): Promise => { + await truncateTables(deps, { truncateTenants: true }) + }) + afterAll(async (): Promise => { + await appContainer.apolloClient.stop() + await appContainer.shutdown() + }) + + describe('whoami', (): void => { + test.each` + isOperator | description + ${true} | ${'operator'} + ${false} | ${'tenant'} + `('whoami query as $description', async ({ isOperator }): Promise => { + const tenant = await createTenant(deps) + const client = isOperator + ? appContainer.apolloClient + : createTenantedApolloClient(appContainer, tenant.id) + + const result = await client + .query({ + query: gql` + query Whoami { + whoami { + id + isOperator + } + } + ` + }) + .then((query): WhoamiResponse => query.data?.whoami) + + expect(result).toEqual({ + id: isOperator ? config.operatorTenantId : tenant.id, + isOperator, + __typename: 'WhoamiResponse' + }) + }) + }) + + describe('Query.tenant', (): void => { + describe('page tests', (): void => { + getPageTests({ + getClient: () => appContainer.apolloClient, + createModel: () => createTenant(deps), + pagedQuery: 'tenants' + }) + + test('Cannot get page as non-operator', async (): Promise => { + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + try { + expect.assertions(2) + await apolloClient + .query({ + query: gql` + query GetTenants { + tenants { + edges { + node { + id + } + } + } + } + ` + }) + .then((query): TenantsConnection => query.data?.tenants) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'cannot get tenants page', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + + test('can get tenant as operator', async (): Promise => { + const tenant = await createTenant(deps) + + const query = await appContainer.apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: tenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + + expect(query).toEqual({ + id: tenant.id, + email: tenant.email, + __typename: 'Tenant' + }) + }) + + test('can get own tenant', async (): Promise => { + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + + const query = await apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: tenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + + expect(query).toEqual({ + id: tenant.id, + email: tenant.email, + __typename: 'Tenant' + }) + }) + + test('cannot get other tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const apolloClient = createTenantedApolloClient( + appContainer, + firstTenant.id + ) + + try { + expect.assertions(2) + await apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: secondTenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'tenant does not exist', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) + }) + + describe('Mutations', (): void => { + describe('Create', (): void => { + test('can create a tenant', async (): Promise => { + const input = generateTenantInput() + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + + expect(mutation.tenant).toEqual({ + ...input, + id: expect.any(String), + __typename: 'Tenant' + }) + }) + + test('can create a tenant with specific id', async (): Promise => { + const inputId = v4() + const input = { ...generateTenantInput(), id: inputId } + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + + expect(mutation.tenant).toEqual({ + ...input, + __typename: 'Tenant' + }) + }) + + test('cannot create tenant with invalid uuid specified', async (): Promise => { + expect.assertions(2) + try { + const input = { ...generateTenantInput(), id: 'invalid-id-format' } + + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: errorToMessage[TenantError.InvalidTenantId], + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) + + test('cannot create tenant as non-operator', async (): Promise => { + const input = generateTenantInput() + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + + try { + expect.assertions(2) + await apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'permission denied', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + describe('Update', (): void => { + let tenantedApolloClient: ApolloClient + let tenant: TenantModel + beforeEach(async (): Promise => { + tenant = await createTenant(deps) + tenantedApolloClient = createTenantedApolloClient( + appContainer, + tenant.id + ) + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + test('can update a tenant as operator', async (): Promise => { + // operator cant update apiSecret + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { apiSecret, ...input } = generateTenantInput() + const updateInput = { + ...input, + id: tenant.id + } + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + + expect(mutation.tenant).toEqual({ + ...updateInput, + __typename: 'Tenant', + apiSecret: expect.any(String) + }) + }) + + test('can update a tenant as tenant', async (): Promise => { + const updateInput = { + ...generateTenantInput(), + id: tenant.id + } + + const mutation = await tenantedApolloClient + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + + expect(mutation.tenant).toEqual({ + ...updateInput, + __typename: 'Tenant' + }) + }) + + test('Cannot update API secret as operator', async (): Promise => { + const updateInput = { + ...generateTenantInput(), + id: tenant.id, + apiSecret: 'newApiSecretValue' + } + + try { + expect.assertions(2) + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Operator cannot update apiSecret over admin api', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) + + test('Cannot update other tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const updateInput = { + ...generateTenantInput(), + id: secondTenant.id + } + const client = createTenantedApolloClient(appContainer, firstTenant.id) + try { + expect.assertions(2) + await client + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'tenant does not exist', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) + }) + + describe('Delete', (): void => { + test('Can delete a tenant as operator', async (): Promise => { + const tenant = await createTenant(deps) + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation DeleteTenant($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: tenant.id + } + }) + .then( + (query): DeleteTenantMutationResponse => query.data?.deleteTenant + ) + + expect(mutation.success).toBe(true) + }) + + test('Cannot delete tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const client = createTenantedApolloClient(appContainer, secondTenant.id) + + try { + expect.assertions(2) + await client + .mutate({ + mutation: gql` + mutation DeleteTenant($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: firstTenant.id + } + }) + .then( + (query): DeleteTenantMutationResponse => query.data?.deleteTenant + ) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'permission denied', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + }) +}) diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts new file mode 100644 index 0000000000..ce1505f945 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -0,0 +1,200 @@ +import { GraphQLError } from 'graphql' +import { TenantedApolloContext } from '../../app' +import { + MutationResolvers, + QueryResolvers, + ResolversTypes, + Tenant as SchemaTenant +} from '../generated/graphql' +import { GraphQLErrorCode } from '../errors' +import { Tenant } from '../../tenants/model' +import { Pagination, SortOrder } from '../../shared/baseModel' +import { getPageInfo } from '../../shared/pagination' +import { tenantSettingsToGraphql } from './tenant_settings' +import { errorToMessage, isTenantError } from '../../tenants/errors' + +export const whoami: QueryResolvers['whoami'] = async ( + parent, + args, + ctx +): Promise => { + const { tenant, isOperator } = ctx + + return { + id: tenant.id, + isOperator + } +} + +export const getTenant: QueryResolvers['tenant'] = + async (parent, args, ctx): Promise => { + const { tenant: contextTenant, isOperator } = ctx + + // TODO: make this a util + // If the tenant that was authorized in the request is not the tenant being requested, + // or the requester is not the operator, return not found + if (args.id !== contextTenant.id && !isOperator) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get(args.id, isOperator) + if (!tenant) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + return tenantToGraphQl(tenant) + } + +export const getTenants: QueryResolvers['tenants'] = + async (parent, args, ctx): Promise => { + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('cannot get tenants page', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const tenantService = await ctx.container.use('tenantService') + + const tenants = await tenantService.getPage(pagination, order) + + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + tenantService.getPage(pagination, sortOrder), + page: tenants, + sortOrder: order + }) + return { + pageInfo, + edges: tenants.map((tenant: Tenant) => ({ + cursor: tenant.id, + node: tenantToGraphQl(tenant) + })) + } + } + +export const createTenant: MutationResolvers['createTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + // createTenant is an operator-only resolver + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('permission denied', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + const tenantOrError = await tenantService.create(args.input) + if (isTenantError(tenantOrError)) { + throw new GraphQLError(errorToMessage[tenantOrError], { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + }) + } + + return { tenant: tenantToGraphQl(tenantOrError) } + } + +export const updateTenant: MutationResolvers['updateTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + const { tenant: contextTenant, isOperator } = ctx + // TODO: make this a util + if (args.input.id !== contextTenant.id && !isOperator) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + if (isOperator && 'apiSecret' in args.input) { + throw new GraphQLError( + 'Operator cannot update apiSecret over admin api', + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + } + + const tenantService = await ctx.container.use('tenantService') + try { + const updatedTenant = await tenantService.update(args.input) + return { tenant: tenantToGraphQl(updatedTenant) } + } catch (err) { + throw new GraphQLError('failed to update tenant', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + } + +export const deleteTenant: MutationResolvers['deleteTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('permission denied', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + try { + await tenantService.delete(args.id) + return { success: true } + } catch (err) { + throw new GraphQLError('failed to delete tenant', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + } + +export function tenantToGraphQl(tenant: Tenant): SchemaTenant { + return { + id: tenant.id, + email: tenant.email, + apiSecret: tenant.apiSecret, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret, + publicName: tenant.publicName, + settings: tenantSettingsToGraphql(tenant.settings), + createdAt: new Date(+tenant.createdAt).toISOString(), + deletedAt: tenant.deletedAt + ? new Date(+tenant.deletedAt).toISOString() + : null + } +} diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts new file mode 100644 index 0000000000..a27219fc44 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts @@ -0,0 +1,229 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createTestApp, TestContainer } from '../../tests/app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { truncateTables } from '../../tests/tableManager' +import { createTenant } from '../../tests/tenant' +import { + CreateTenantSettingsInput, + CreateTenantSettingsMutationResponse +} from '../generated/graphql' +import { + ApolloClient, + NormalizedCacheObject, + createHttpLink, + ApolloLink, + InMemoryCache, + gql, + ApolloError +} from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { TenantSettingKeys } from '../../tenants/settings/model' +import { faker } from '@faker-js/faker' +import { + errorToCode, + errorToMessage, + TenantSettingError +} from '../../tenants/settings/errors' + +function createTenantedApolloClient( + appContainer: TestContainer, + tenantId: string +): ApolloClient { + const httpLink = createHttpLink({ + uri: `http://localhost:${appContainer.app.getAdminPort()}/graphql`, + fetch + }) + const authLink = setContext((_, { headers }) => { + return { + headers: { + ...headers, + 'tenant-id': tenantId + } + } + }) + + const link = ApolloLink.from([authLink, httpLink]) + + return new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) +} + +describe('Tenant Settings Resolvers', (): void => { + let deps: IocContract + let appContainer: TestContainer + const dbSchema = 'tenant_settings_resolver_test_schema' + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + dbSchema + }) + appContainer = await createTestApp(deps) + + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + }) + + afterEach(async (): Promise => { + await truncateTables(deps, { truncateTenants: true }) + }) + + afterAll(async (): Promise => { + appContainer.apolloClient.stop() + await appContainer.shutdown() + }) + + describe('Create Tenant Settings', (): void => { + test('can create tenant setting', async (): Promise => { + const input: CreateTenantSettingsInput = { + settings: [ + { + key: TenantSettingKeys.EXCHANGE_RATES_URL.name, + value: faker.internet.url() + } + ] + } + + const tenant = await createTenant(deps) + const client = createTenantedApolloClient(appContainer, tenant.id) + const response = await client + .mutate({ + mutation: gql` + mutation CreateTenantSettings($input: CreateTenantSettingsInput!) { + createTenantSettings(input: $input) { + settings { + key + value + } + } + } + `, + variables: { input } + }) + .then((query): CreateTenantSettingsMutationResponse => { + if (query.data) { + return query.data.createTenantSettings + } + throw new Error('Data was empty') + }) + + expect(response.settings.length).toBeGreaterThan(0) + }) + + test('errors when invalid input is provided', async (): Promise => { + const input: CreateTenantSettingsInput = { + settings: [ + { + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '-1' + } + ] + } + + const tenant = await createTenant(deps) + const client = createTenantedApolloClient(appContainer, tenant.id) + expect.assertions(2) + + try { + await client + .mutate({ + mutation: gql` + mutation CreateTenantSettings( + $input: CreateTenantSettingsInput! + ) { + createTenantSettings(input: $input) { + settings { + key + value + } + } + } + `, + variables: { input } + }) + .then((query): CreateTenantSettingsMutationResponse => { + if (query.data) { + return query.data.createTenantSettings + } + throw new Error('Data was empty') + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: errorToMessage[TenantSettingError.InvalidSetting], + extensions: expect.objectContaining({ + code: errorToCode[TenantSettingError.InvalidSetting] + }) + }) + ) + } + }) + }) + + describe('Get Tenant Settings', (): void => { + test('can get tenant settings', async (): Promise => { + const tenant = await createTenant(deps) + const client = createTenantedApolloClient(appContainer, tenant.id) + + // Query the settings + const response = await client + .query({ + query: gql` + query GetTenantSettings($id: String!) { + tenant(id: $id) { + settings { + key + value + } + } + } + `, + variables: { id: tenant.id } + }) + .then((query): { key: string; value: string }[] => { + if (query.data && query.data.tenant) { + return query.data.tenant.settings + } + throw new Error('Data was empty') + }) + + expect(response.length).toBeGreaterThan(0) + expect(response).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: String(TenantSettingKeys.WEBHOOK_MAX_RETRY.default) + }), + expect.objectContaining({ + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: String(TenantSettingKeys.WEBHOOK_TIMEOUT.default) + }) + ]) + ) + }) + }) +}) diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.ts b/packages/backend/src/graphql/resolvers/tenant_settings.ts new file mode 100644 index 0000000000..3e98fc149a --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant_settings.ts @@ -0,0 +1,73 @@ +import { GraphQLError } from 'graphql' +import { TenantedApolloContext } from '../../app' +import { + isTenantSettingError, + errorToCode, + errorToMessage +} from '../../tenants/settings/errors' +import { TenantSetting } from '../../tenants/settings/model' +import { + ResolversTypes, + TenantResolvers, + TenantSetting as SchemaTenantSetting, + MutationResolvers +} from '../generated/graphql' + +export const getTenantSettings: TenantResolvers['settings'] = + async (parent, args, ctx): Promise => { + if (!parent.id) { + throw new Error('missing tenant id') + } + + const tenantSettingsService = await ctx.container.use( + 'tenantSettingService' + ) + + const tenantSettings = await tenantSettingsService.get({ + tenantId: parent.id + }) + + return tenantSettingsToGraphql(tenantSettings) + } + +export const createTenantSettings: MutationResolvers['createTenantSettings'] = + async ( + parent, + args, + ctx + ): Promise => { + const tenantSettingService = await ctx.container.use('tenantSettingService') + + const tenantSettingsOrError = await tenantSettingService.create({ + tenantId: ctx.tenant.id, + setting: args.input.settings + }) + + if (isTenantSettingError(tenantSettingsOrError)) { + throw new GraphQLError(errorToMessage[tenantSettingsOrError], { + extensions: { + code: errorToCode[tenantSettingsOrError] + } + }) + } + + return { + settings: tenantSettingsToGraphql(tenantSettingsOrError) + } + } + +const tenantSettingToGraphql = ( + tenantSetting: TenantSetting +): SchemaTenantSetting => ({ + key: tenantSetting.key, + value: tenantSetting.value +}) + +export const tenantSettingsToGraphql = ( + tenantSettings?: TenantSetting[] +): SchemaTenantSetting[] => { + if (!tenantSettings) { + return [] + } + return tenantSettings.map((x) => tenantSettingToGraphql(x)) +} diff --git a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts index fb2f133e0d..9cd736f973 100644 --- a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts +++ b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts @@ -41,7 +41,7 @@ describe('Wallet Address Key Resolvers', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -51,7 +51,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Create Wallet Address Keys', (): void => { test('Can create wallet address key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -104,9 +106,10 @@ describe('Wallet Address Key Resolvers', (): void => { revoked: false }) }) - test('Cannot add duplicate key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -172,7 +175,9 @@ describe('Wallet Address Key Resolvers', (): void => { throw new Error('unexpected') }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input = { walletAddressId: walletAddress.id, @@ -230,7 +235,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Revoke key', (): void => { test('Can revoke a key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const key = await walletAddressKeyService.create({ walletAddressId: walletAddress.id, @@ -334,7 +341,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('List Wallet Address Keys', (): void => { let walletAddressId: string beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps)).id + walletAddressId = ( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ).id }) getPageTests({ getClient: () => appContainer.apolloClient, diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index 8f559d8a40..005bb2c421 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -3,7 +3,11 @@ import { gql, ApolloError } from '@apollo/client' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' -import { createTestApp, TestContainer } from '../../tests/app' +import { + createApolloClient, + createTestApp, + TestContainer +} from '../../tests/app' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../../app' import { Asset } from '../../asset/model' @@ -35,25 +39,33 @@ import { import { getPageTests } from './page.test' import { WalletAddressAdditionalProperty } from '../../open_payments/wallet_address/additional_property/model' import { GraphQLErrorCode } from '../errors' +import { AssetService } from '../../asset/service' +import { faker } from '@faker-js/faker' +import { Tenant } from '../../tenants/model' +import { createTenantSettings } from '../../tests/tenantSettings' +import { TenantSettingKeys } from '../../tenants/settings/model' +import { createTenant } from '../../tests/tenant' describe('Wallet Address Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer let knex: Knex let walletAddressService: WalletAddressService + let assetService: AssetService beforeAll(async (): Promise => { - deps = await initIocContainer({ + deps = initIocContainer({ ...Config, localCacheDuration: 0 }) appContainer = await createTestApp(deps) knex = appContainer.knex walletAddressService = await deps.use('walletAddressService') + assetService = await deps.use('assetService') }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -61,6 +73,18 @@ describe('Wallet Address Resolvers', (): void => { await appContainer.shutdown() }) + beforeEach(async () => { + await createTenantSettings(deps, { + tenantId: Config.operatorTenantId, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://alice.me' + } + ] + }) + }) + describe('Create Wallet Address', (): void => { let asset: Asset let input: CreateWalletAddressInput @@ -69,7 +93,8 @@ describe('Wallet Address Resolvers', (): void => { asset = await createAsset(deps) input = { assetId: asset.id, - url: 'https://alice.me/.well-known/pay' + tenantId: Config.operatorTenantId, + address: 'https://alice.me/.well-known/pay' } }) @@ -92,7 +117,7 @@ describe('Wallet Address Resolvers', (): void => { code scale } - url + address publicName } } @@ -114,7 +139,7 @@ describe('Wallet Address Resolvers', (): void => { expect(response.walletAddress).toEqual({ __typename: 'WalletAddress', id: response.walletAddress.id, - url: input.url, + address: input.address, asset: { __typename: 'Asset', code: asset.code, @@ -158,7 +183,7 @@ describe('Wallet Address Resolvers', (): void => { code scale } - url + address publicName additionalProperties { key @@ -185,7 +210,7 @@ describe('Wallet Address Resolvers', (): void => { expect(response.walletAddress).toEqual({ __typename: 'WalletAddress', id: response.walletAddress.id, - url: input.url, + address: input.address, asset: { __typename: 'Asset', code: asset.code, @@ -306,13 +331,144 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant create', async (): Promise => { + // Make request as non-operator. + const nonOperatorTenant = await createTenant(deps) + const tenantedApolloClient = await createApolloClient( + appContainer.container, + appContainer.app, + nonOperatorTenant.id + ) + + const badInputData = { + tenantId: uuid(), // some tenant other than requestor + assetId: input.assetId, + address: input.address + } + try { + expect.assertions(2) + await tenantedApolloClient + .mutate({ + mutation: gql` + mutation CreateWalletAddress( + $badInputData: CreateWalletAddressInput! + ) { + createWalletAddress(input: $badInputData) { + walletAddress { + id + asset { + code + scale + } + } + } + } + `, + variables: { + badInputData + } + }) + .then((query): CreateWalletAddressMutationResponse => { + if (query.data) { + return query.data.createWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Assignment to the specified tenant is not permitted', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) + + test('Operator can perform cross tenant create', async (): Promise => { + // Setup non-tenant operator and form request for it from operator + const nonOperatorTenant = await createTenant(deps) + const asset = await createAsset(deps, { + assetOptions: { + code: 'xyz', + scale: 2 + }, + tenantId: nonOperatorTenant.id + }) + await createTenantSettings(deps, { + tenantId: nonOperatorTenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://bob.me' + } + ] + }) + + const input = { + tenantId: nonOperatorTenant.id, + assetId: asset.id, + address: 'https://bob.me/.well-known/pay' + } + const response = await appContainer.apolloClient // operator client + .mutate({ + mutation: gql` + mutation CreateWalletAddress($input: CreateWalletAddressInput!) { + createWalletAddress(input: $input) { + walletAddress { + id + asset { + code + scale + } + address + } + } + } + `, + variables: { + input + } + }) + .then((query): CreateWalletAddressMutationResponse => { + if (query.data) { + return query.data.createWalletAddress + } else { + throw new Error('Data was empty') + } + }) + + assert.ok(response.walletAddress) + expect(response.walletAddress).toEqual({ + __typename: 'WalletAddress', + id: response.walletAddress.id, + address: input.address, + asset: { + __typename: 'Asset', + code: asset.code, + scale: asset.scale + } + }) + await expect( + walletAddressService.get(response.walletAddress.id) + ).resolves.toMatchObject({ + id: response.walletAddress.id, + asset + }) + }) }) describe('Update Wallet Address', (): void => { let walletAddress: WalletAddressModel beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) test('Can update a wallet address', async (): Promise => { @@ -426,6 +582,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('New additional properties override previous additional properties', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -492,6 +649,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('Updating with empty additional properties deletes existing', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -634,6 +792,79 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant update', async (): Promise => { + expect.assertions(2) + try { + const tenantOptions = { + apiSecret: 'test-api-secret-new', + publicName: 'test tenant new', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-new' + } + const newTenant = await Tenant.query(knex).insertAndFetch(tenantOptions) + const newAsset = await assetService.create({ + code: 'USD', + scale: 2, + tenantId: newTenant!.id + }) + + await createTenantSettings(deps, { + tenantId: newTenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://alice.me' + } + ] + }) + + const newWalletAddress = await walletAddressService.create({ + assetId: (newAsset as Asset).id, + tenantId: newTenant!.id, + address: 'https://alice.me/.well-known/pay-2' + }) + const id = (newWalletAddress as WalletAddressModel).id + + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation UpdateWalletAddress($input: UpdateWalletAddressInput!) { + updateWalletAddress(input: $input) { + walletAddress { + id + status + } + } + } + `, + variables: { + input: { + id, + status: WalletAddressStatus.Inactive + } + } + }) + .then((query): UpdateWalletAddressMutationResponse => { + if (query.data) { + return query.data.updateWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Unknown wallet address', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) }) describe('Wallet Address Queries', (): void => { @@ -657,7 +888,8 @@ describe('Wallet Address Resolvers', (): void => { const walletAddress = await createWalletAddress(deps, { publicName, createLiquidityAccount: true, - additionalProperties + additionalProperties, + tenantId: Config.operatorTenantId }) const query = await appContainer.apolloClient .query({ @@ -670,7 +902,7 @@ describe('Wallet Address Resolvers', (): void => { code scale } - url + address publicName additionalProperties { key @@ -701,7 +933,7 @@ describe('Wallet Address Resolvers', (): void => { code: walletAddress.asset.code, scale: walletAddress.asset.scale }, - url: walletAddress.url, + address: walletAddress.address, publicName: publicName ?? null, additionalProperties: [ { @@ -729,10 +961,11 @@ describe('Wallet Address Resolvers', (): void => { 'Can get a wallet address by its url (publicName: $publicName)', async ({ publicName }): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName, createLiquidityAccount: true }) - const args = { url: walletAddress.url } + const args = { url: walletAddress.address } const query = await appContainer.apolloClient .query({ query: gql` @@ -744,7 +977,7 @@ describe('Wallet Address Resolvers', (): void => { code scale } - url + address publicName additionalProperties { key @@ -773,7 +1006,7 @@ describe('Wallet Address Resolvers', (): void => { code: walletAddress.asset.code, scale: walletAddress.asset.scale }, - url: walletAddress.url, + address: walletAddress.address, publicName: publicName ?? null, additionalProperties: [] }) @@ -818,14 +1051,17 @@ describe('Wallet Address Resolvers', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), pagedQuery: 'walletAddresses' }) test('Can get page of wallet addresses', async (): Promise => { const walletAddresses: WalletAddressModel[] = [] for (let i = 0; i < 2; i++) { - walletAddresses.push(await createWalletAddress(deps)) + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) } walletAddresses.reverse() // Calling the default getPage will result in descending order const query = await appContainer.apolloClient @@ -840,7 +1076,7 @@ describe('Wallet Address Resolvers', (): void => { code scale } - url + address publicName } cursor @@ -869,7 +1105,65 @@ describe('Wallet Address Resolvers', (): void => { code: walletAddress.asset.code, scale: walletAddress.asset.scale }, - url: walletAddress.url, + address: walletAddress.address, + publicName: walletAddress.publicName + }) + }) + }) + + test('Can get page of wallet addresses with tenantId param', async (): Promise => { + const walletAddresses: WalletAddressModel[] = [] + for (let i = 0; i < 2; i++) { + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) + } + walletAddresses.reverse() // Calling the default getPage will result in descending order + const query = await appContainer.apolloClient + .query({ + query: gql` + query WalletAddresses($tenantId: String) { + walletAddresses(tenantId: $tenantId) { + edges { + node { + id + asset { + code + scale + } + address + publicName + } + cursor + } + } + } + `, + variables: { + tenantId: Config.operatorTenantId + } + }) + .then((query): WalletAddressesConnection => { + if (query.data) { + return query.data.walletAddresses + } else { + throw new Error('Data was empty') + } + }) + + expect(query.edges).toHaveLength(2) + query.edges.forEach((edge, idx) => { + const walletAddress = walletAddresses[idx] + expect(edge.cursor).toEqual(walletAddress.id) + expect(edge.node).toEqual({ + __typename: 'WalletAddress', + id: walletAddress.id, + asset: { + __typename: 'Asset', + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + address: walletAddress.address, publicName: walletAddress.publicName }) }) @@ -889,6 +1183,7 @@ describe('Wallet Address Resolvers', (): void => { const withdrawalAmount = BigInt(10) for (let i = 0; i < 3; i++) { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) if (i) { diff --git a/packages/backend/src/graphql/resolvers/wallet_address.ts b/packages/backend/src/graphql/resolvers/wallet_address.ts index d1f7172dab..1fe36e61b7 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.ts @@ -8,7 +8,7 @@ import { MutationResolvers, WalletAddressStatus } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { WalletAddressError, isWalletAddressError, @@ -23,19 +23,22 @@ import { CreateOptions, UpdateOptions } from '../../open_payments/wallet_address/service' +import { GraphQLErrorCode } from '../errors' -export const getWalletAddresses: QueryResolvers['walletAddresses'] = +export const getWalletAddresses: QueryResolvers['walletAddresses'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const { sortOrder, ...pagination } = args + const { tenantId, sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const walletAddresses = await walletAddressService.getPage( pagination, - order + order, + ctx.isOperator ? tenantId : ctx.tenant.id ) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => @@ -52,10 +55,13 @@ export const getWalletAddresses: QueryResolvers['walletAddresses' } } -export const getWalletAddress: QueryResolvers['walletAddress'] = +export const getWalletAddress: QueryResolvers['walletAddress'] = async (parent, args, ctx): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.get(args.id) + const walletAddress = await walletAddressService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!walletAddress) { throw new GraphQLError( errorToMessage[WalletAddressError.UnknownWalletAddress], @@ -69,18 +75,21 @@ export const getWalletAddress: QueryResolvers['walletAddress'] = return walletAddressToGraphql(walletAddress) } -export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = +export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.getByUrl(args.url) + const walletAddress = await walletAddressService.getByUrl( + args.url, + ctx.isOperator ? undefined : ctx.tenant.id + ) return walletAddress ? walletAddressToGraphql(walletAddress) : null } -export const createWalletAddress: MutationResolvers['createWalletAddress'] = +export const createWalletAddress: MutationResolvers['createWalletAddress'] = async ( parent, args, @@ -97,11 +106,25 @@ export const createWalletAddress: MutationResolvers['createWallet addProps.push(toAdd) }) + const tenantId = ctx.forTenantId + + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + const options: CreateOptions = { assetId: args.input.assetId, + tenantId, additionalProperties: addProps, publicName: args.input.publicName, - url: args.input.url + address: args.input.address, + isOperator: ctx.isOperator } const walletAddressOrError = await walletAddressService.create(options) @@ -117,7 +140,7 @@ export const createWalletAddress: MutationResolvers['createWallet } } -export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = +export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = async ( parent, args, @@ -125,9 +148,23 @@ export const updateWalletAddress: MutationResolvers['updateWallet ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') const { additionalProperties, ...rest } = args.input + const updateOptions: UpdateOptions = { ...rest } + + const existing = await walletAddressService.get( + updateOptions.id, + ctx.forTenantId + ) + if (!existing) { + throw new GraphQLError(`Unknown wallet address`, { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + if (additionalProperties) { updateOptions.additionalProperties = additionalProperties.map( (property) => { @@ -153,7 +190,7 @@ export const updateWalletAddress: MutationResolvers['updateWallet } } -export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = +export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = async ( parent, args, @@ -166,15 +203,18 @@ export const triggerWalletAddressEvents: MutationResolvers['trigg } } -export const walletAddressToGraphql = ( +export function walletAddressToGraphql( walletAddress: WalletAddress -): SchemaWalletAddress => ({ - id: walletAddress.id, - url: walletAddress.url, - asset: assetToGraphql(walletAddress.asset), - publicName: walletAddress.publicName ?? undefined, - createdAt: new Date(+walletAddress.createdAt).toISOString(), - status: walletAddress.isActive - ? WalletAddressStatus.Active - : WalletAddressStatus.Inactive -}) +): SchemaWalletAddress { + return { + id: walletAddress.id, + address: walletAddress.address, + asset: assetToGraphql(walletAddress.asset), + publicName: walletAddress.publicName ?? undefined, + createdAt: new Date(+walletAddress.createdAt).toISOString(), + status: walletAddress.isActive + ? WalletAddressStatus.Active + : WalletAddressStatus.Inactive, + tenantId: walletAddress.tenantId + } +} diff --git a/packages/backend/src/graphql/resolvers/webhooks.test.ts b/packages/backend/src/graphql/resolvers/webhooks.test.ts index b5ecda5ccb..9c205c92aa 100644 --- a/packages/backend/src/graphql/resolvers/webhooks.test.ts +++ b/packages/backend/src/graphql/resolvers/webhooks.test.ts @@ -8,7 +8,7 @@ import { Config } from '../../config/app' import { truncateTables } from '../../tests/tableManager' import { WebhookEventsConnection } from '../generated/graphql' import { createWebhookEvent, webhookEventTypes } from '../../tests/webhook' -import { WebhookEvent } from '../../webhook/model' +import { WebhookEvent } from '../../webhook/event/model' describe('Webhook Events Query', (): void => { let deps: IocContract @@ -20,7 +20,7 @@ describe('Webhook Events Query', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/graphql/resolvers/webhooks.ts b/packages/backend/src/graphql/resolvers/webhooks.ts index 57ecf6496c..cfd514af9d 100644 --- a/packages/backend/src/graphql/resolvers/webhooks.ts +++ b/packages/backend/src/graphql/resolvers/webhooks.ts @@ -1,27 +1,28 @@ -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { QueryResolvers, ResolversTypes, WebhookEvent as SchemaWebhookEvent } from '../generated/graphql' import { getPageInfo } from '../../shared/pagination' -import { WebhookEvent } from '../../webhook/model' +import { WebhookEvent } from '../../webhook/event/model' import { Pagination, SortOrder } from '../../shared/baseModel' -export const getWebhookEvents: QueryResolvers['webhookEvents'] = +export const getWebhookEvents: QueryResolvers['webhookEvents'] = async ( parent, args, ctx ): Promise => { - const { filter, sortOrder, ...pagination } = args + const { filter, sortOrder, tenantId, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc const webhookService = await ctx.container.use('webhookService') const getPageFn = (pagination_: Pagination, sortOrder_?: SortOrder) => webhookService.getPage({ pagination: pagination_, filter, - sortOrder: sortOrder_ + sortOrder: sortOrder_, + tenantId: ctx.isOperator ? tenantId : ctx.tenant.id }) const webhookEvents = await getPageFn(pagination, order) const pageInfo = await getPageInfo({ @@ -45,5 +46,6 @@ export const webhookEventToGraphql = ( id: webhookEvent.id, type: webhookEvent.type, data: webhookEvent.data, + tenantId: webhookEvent.tenantId, createdAt: new Date(webhookEvent.createdAt).toISOString() }) diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 611729376e..4750a2b300 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -16,12 +16,14 @@ type Query { after: String "Backward pagination: Cursor (asset ID) to start retrieving assets before this point." before: String - "Foward pagination: Limit the result to the first **n** assets after the `after` cursor." + "Forward pagination: Limit the result to the first **n** assets after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** assets before the `before` cursor." last: Int "Specify the sort order of assets based on their creation data, either ascending or descending." sortOrder: SortOrder + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): AssetsConnection! "Fetch a peer by its ID." @@ -47,6 +49,8 @@ type Query { last: Int "Specify the sort order of peers based on their creation date, either ascending or descending." sortOrder: SortOrder + "Unique identifier of the tenant associated with the peer." + tenantId: ID ): PeersConnection! "Fetch a wallet address by its ID." @@ -70,6 +74,8 @@ type Query { last: Int "Specify the sort order of wallet addresses based on their creation date, either ascending or descending." sortOrder: SortOrder + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): WalletAddressesConnection! "Fetch an Open Payments quote by its ID." @@ -95,6 +101,8 @@ type Query { sortOrder: SortOrder "Filter outgoing payments based on specific criteria such as receiver, wallet address ID, or state." filter: OutgoingPaymentFilter + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): OutgoingPaymentConnection! "Fetch an Open Payments incoming payment by its ID." @@ -117,6 +125,8 @@ type Query { sortOrder: SortOrder "Filter webhook events based on specific criteria." filter: WebhookEventFilter + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): WebhookEventsConnection! "Fetch a paginated list of combined payments, including incoming and outgoing payments." @@ -133,6 +143,8 @@ type Query { sortOrder: SortOrder "Filter payment events based on specific criteria such as payment type or wallet address ID." filter: PaymentFilter + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): PaymentConnection! "Fetch a paginated list of accounting transfers for a given account." @@ -148,6 +160,26 @@ type Query { "Unique identifier of the receiver (incoming payment URL)." id: String! ): Receiver + + "Retrieve a tenant of the instance." + tenant("Unique identifier of the tenant." id: String!): Tenant! + + "As an operator, fetch a paginated list of tenants on the instance." + tenants( + "Forward pagination: Cursor (tenant ID) to start retrieving tenants after this point." + after: String + "Backward pagination: Cursor (tenant ID) to start retrieving tenants before this point." + before: String + "Forward pagination: Limit the result to the first **n** tenants after the `after` cursor." + first: Int + "Backward pagination: Limit the result to the last **n** tenants before the `before` cursor." + last: Int + "Specify the sort order of tenants based on their creation date, either ascending or descending." + sortOrder: SortOrder + ): TenantsConnection! + + "Determine if the requester has operator permissions" + whoami: WhoamiResponse! } type Mutation { @@ -219,6 +251,10 @@ type Mutation { input: CreateWalletAddressKeyInput! ): CreateWalletAddressKeyMutationResponse + createTenantSettings( + input: CreateTenantSettingsInput! + ): CreateTenantSettingsMutationResponse + "Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward." revokeWalletAddressKey( input: RevokeWalletAddressKeyInput! @@ -306,6 +342,15 @@ type Mutation { cancelIncomingPayment( input: CancelIncomingPaymentInput! ): CancelIncomingPaymentResponse! + + "As an operator, create a tenant." + createTenant(input: CreateTenantInput!): TenantMutationResponse! + + "Update a tenant." + updateTenant(input: UpdateTenantInput!): TenantMutationResponse! + + "Delete a tenant." + deleteTenant(id: String!): DeleteTenantMutationResponse! } type PageInfo { @@ -319,6 +364,11 @@ type PageInfo { startCursor: String } +type WhoamiResponse { + id: String! + isOperator: Boolean! +} + type AssetsConnection { "Information to aid in pagination." pageInfo: PageInfo! @@ -354,6 +404,8 @@ input CreateAssetInput { liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String + "Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: ID } input UpdateAssetInput { @@ -594,6 +646,18 @@ input CreateWalletAddressKeyInput { idempotencyKey: String } +input CreateTenantSettingsInput { + "List of a settings for a tenant." + settings: [TenantSettingInput!]! +} + +input TenantSettingInput { + "Key for this setting." + key: String! + "Value of a setting for this key." + value: String! +} + input RevokeWalletAddressKeyInput { "Internal unique identifier of the key to revoke." id: String! @@ -624,7 +688,7 @@ type Asset implements Model { after: String "Backward pagination: Cursor (fee ID) to start retrieving fees before this point." before: String - "Foward pagination: Limit the result to the first **n** fees after the `after` cursor." + "Forward pagination: Limit the result to the first **n** fees after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** fees before the `before` cursor." last: Int @@ -633,6 +697,7 @@ type Asset implements Model { ): FeesConnection "The date and time when the asset was created." createdAt: String! + tenantId: ID! } enum SortOrder { @@ -688,6 +753,8 @@ type Peer implements Model { liquidity: UInt64 "The date and time when the peer was created." createdAt: String! + "Unique identifier of the tenant associated with the peer." + tenantId: ID! } input DeletePeerInput { @@ -733,8 +800,8 @@ type WalletAddress implements Model { "Current amount of liquidity available for this wallet address." liquidity: UInt64 - "Wallet Address URL." - url: String! + "Wallet Address." + address: String! "Public name associated with the wallet address. This is visible to anyone with the wallet address URL." publicName: String @@ -759,7 +826,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (quote ID) to start retrieving quotes before this point." before: String - "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor." + "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** quotes before the `before` cursor." last: Int @@ -793,7 +860,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (wallet address key ID) to start retrieving keys before this point." before: String - "Foward pagination: Limit the result to the first **n** keys after the `after` cursor." + "Forward pagination: Limit the result to the first **n** keys after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** keys before the `before` cursor." last: Int @@ -803,6 +870,9 @@ type WalletAddress implements Model { "Additional properties associated with the wallet address." additionalProperties: [AdditionalProperty] + + "Tenant ID of the wallet address." + tenantId: String } type AdditionalProperty { @@ -883,6 +953,8 @@ type IncomingPayment implements BasePayment & Model { metadata: JSONObject "The date and time that the incoming payment was created." createdAt: String! + "The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature." + tenantId: String } type Receiver { @@ -978,6 +1050,8 @@ type OutgoingPayment implements BasePayment & Model { createdAt: String! "Unique identifier of the grant under which the outgoing payment was created." grantId: String + "Tenant ID of the outgoing payment." + tenantId: String } enum OutgoingPaymentState { @@ -1101,6 +1175,8 @@ type QuoteEdge { type Quote { "Unique identifier of the quote." id: ID! + "Unique identifier of the tenant under which the quote was created." + tenantId: ID! "Unique identifier of the wallet address under which the quote was created." walletAddressId: ID! "Wallet address URL of the receiver." @@ -1224,10 +1300,12 @@ type CreateReceiverResponse { } input CreateWalletAddressInput { + "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: ID "Unique identifier of the asset associated with the wallet address. This cannot be changed." assetId: String! - "Wallet address URL. This cannot be changed." - url: String! + "Wallet address. This cannot be changed." + address: String! "Public name associated with the wallet address. This is visible to anyone with the wallet address URL." publicName: String "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." @@ -1318,6 +1396,8 @@ type WalletAddressWithdrawal { type WebhookEvent implements Model { "Unique identifier of the webhook event." id: ID! + "Tenant of the webhook event." + tenantId: ID! "Type of webhook event." type: String! "Stringified JSON data for the webhook event." @@ -1471,6 +1551,11 @@ type CreateWalletAddressKeyMutationResponse { walletAddressKey: WalletAddressKey } +type CreateTenantSettingsMutationResponse { + "New tenant settings." + settings: [TenantSetting!]! +} + type RevokeWalletAddressKeyMutationResponse { "The wallet address key that was revoked." walletAddressKey: WalletAddressKey @@ -1491,6 +1576,88 @@ type CancelIncomingPaymentResponse { payment: IncomingPayment } +type Tenant implements Model { + "Unique identifier of the tenant." + id: ID! + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String! + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String + "The date and time that this tenant was created." + createdAt: String! + "The date and time that this tenant was deleted." + deletedAt: String + "List of settings for the tenant." + settings: [TenantSetting!]! +} + +type TenantsConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges representing tenants and cursors for pagination." + edges: [TenantEdge!]! +} + +type TenantEdge { + "A tenant node in the list." + node: Tenant! + "A cursor for paginating through the tenants." + cursor: String! +} + +type TenantSetting { + "Key for this setting." + key: String! + "Value of a setting for this key." + value: String! +} + +input CreateTenantInput { + "Unique identifier of the tenant. Must be compliant with uuid v4. Will be generated automatically if not provided." + id: ID + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String! + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String + "Initial settings for tenant." + settings: [TenantSettingInput!] +} + +input UpdateTenantInput { + "Unique identifier of the tenant." + id: ID! + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String +} + +type TenantMutationResponse { + tenant: Tenant! +} + +type DeleteTenantMutationResponse { + success: Boolean! +} + """ The `UInt8` scalar type represents unsigned 8-bit whole numeric values, ranging from 0 to 255. """ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bf85d24b1d..73cbdfbab6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,6 +7,7 @@ import { createClient } from 'tigerbeetle-node' import { createClient as createIntrospectionClient } from 'token-introspection' import net from 'net' import dns from 'dns' +import { createHmac } from 'crypto' import { createAuthenticatedClient as createOpenPaymentsClient, @@ -15,6 +16,17 @@ import { } from '@interledger/open-payments' import { StreamServer } from '@interledger/stream-receiver' import axios from 'axios' +import { + ApolloClient, + ApolloLink, + createHttpLink, + InMemoryCache +} from '@apollo/client' +import { onError } from '@apollo/client/link/error' +import { setContext } from '@apollo/client/link/context' +import { canonicalize } from 'json-canonicalize' +import { print } from 'graphql/language/printer' + import { createAccountingService as createPsqlAccountingService } from './accounting/psql/service' import { createAccountingService as createTigerbeetleAccountingService } from './accounting/tigerbeetle/service' import { App, AppServices } from './app' @@ -61,6 +73,10 @@ import { } from './telemetry/service' import { createWebhookService } from './webhook/service' import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memory' +import { createTenantService } from './tenants/service' +import { AuthServiceClient } from './auth-service-client/client' +import { createTenantSettingService } from './tenants/settings/service' +import { createPaymentMethodProviderService } from './payment-method/provider/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -93,6 +109,7 @@ export function initIocContainer( directory: './', tableName: 'knex_migrations' }, + searchPath: config.dbSchema, log: { warn(message) { logger.warn(message) @@ -114,6 +131,9 @@ export function initIocContainer( 'text', BigInt ) + if (config.dbSchema) { + await db.raw(`CREATE SCHEMA IF NOT EXISTS "${config.dbSchema}"`) + } return db }) container.singleton('redis', async (deps): Promise => { @@ -131,20 +151,133 @@ export function initIocContainer( }) }) + container.singleton('apolloClient', async (deps) => { + const [logger, config] = await Promise.all([ + deps.use('logger'), + deps.use('config') + ]) + + const httpLink = createHttpLink({ + uri: config.authAdminApiUrl + }) + + const errorLink = onError(({ graphQLErrors }) => { + if (graphQLErrors) { + logger.error(graphQLErrors) + graphQLErrors.map(({ extensions }) => { + if (extensions && extensions.code === 'UNAUTHENTICATED') { + logger.error('UNAUTHENTICATED') + } + + if (extensions && extensions.code === 'FORBIDDEN') { + logger.error('FORBIDDEN') + } + }) + } + }) + + const authLink = setContext((request, { headers }) => { + if (!config.authAdminApiSecret || !config.authAdminApiSignatureVersion) + return { headers } + const timestamp = Date.now() + const version = config.authAdminApiSignatureVersion + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', config.authAdminApiSecret) + hmac.update(payload) + const digest = hmac.digest('hex') + + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` + } + } + }) + + const link = ApolloLink.from([errorLink, authLink, httpLink]) + + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) + + return client + }) + + container.singleton('tenantCache', async () => { + return createInMemoryDataStore(config.localCacheDuration) + }) + + container.singleton('authServiceClient', () => { + return new AuthServiceClient(config.authServiceApiUrl) + }) + + container.singleton('tenantService', async (deps) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + tenantCache: await deps.use('tenantCache'), + authServiceClient: deps.use('authServiceClient'), + tenantSettingService: await deps.use('tenantSettingService'), + config: await deps.use('config') + }) + }) + + container.singleton('tenantSettingService', async (deps) => { + const [logger, knex] = await Promise.all([ + deps.use('logger'), + deps.use('knex') + ]) + return createTenantSettingService({ logger, knex }) + }) + + container.singleton('paymentMethodProviderService', async (deps) => { + return createPaymentMethodProviderService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + config: await deps.use('config'), + streamCredentialsService: await deps.use('streamCredentialsService'), + tenantSettingsService: await deps.use('tenantSettingService') + }) + }) + container.singleton('ratesService', async (deps) => { const config = await deps.use('config') return createRatesService({ logger: await deps.use('logger'), - exchangeRatesUrl: config.exchangeRatesUrl, - exchangeRatesLifetime: config.exchangeRatesLifetime + operatorTenantId: config.operatorTenantId, + operatorExchangeRatesUrl: config.operatorExchangeRatesUrl, + exchangeRatesLifetime: config.exchangeRatesLifetime, + tenantSettingService: await deps.use('tenantSettingService') }) }) container.singleton('internalRatesService', async (deps) => { return createRatesService({ logger: await deps.use('logger'), - exchangeRatesUrl: config.telemetryExchangeRatesUrl, - exchangeRatesLifetime: config.telemetryExchangeRatesLifetime + operatorTenantId: config.operatorTenantId, + operatorExchangeRatesUrl: config.telemetryExchangeRatesUrl, + exchangeRatesLifetime: config.telemetryExchangeRatesLifetime, + tenantSettingService: await deps.use('tenantSettingService') }) }) @@ -212,10 +345,13 @@ export function initIocContainer( container.singleton('assetService', async (deps) => { const logger = await deps.use('logger') const knex = await deps.use('knex') + const config = await deps.use('config') return await createAssetService({ + config: config, logger: logger, knex: knex, accountingService: await deps.use('accountingService'), + tenantSettingService: await deps.use('tenantSettingService'), assetCache: await deps.use('assetCache') }) }) @@ -238,6 +374,7 @@ export function initIocContainer( }) const tigerBeetle = await deps.use('tigerBeetle')! return createTigerbeetleAccountingService({ + config, logger, knex, tigerBeetle, @@ -250,7 +387,8 @@ export function initIocContainer( logger, knex, withdrawalThrottleDelay: config.withdrawalThrottleDelay, - telemetry + telemetry, + config }) }) container.singleton('peerService', async (deps) => { @@ -278,6 +416,7 @@ export function initIocContainer( }) container.singleton('webhookService', async (deps) => { return createWebhookService({ + tenantSettingService: await deps.use('tenantSettingService'), config: await deps.use('config'), knex: await deps.use('knex'), logger: await deps.use('logger') @@ -295,7 +434,8 @@ export function initIocContainer( accountingService: await deps.use('accountingService'), webhookService: await deps.use('webhookService'), assetService: await deps.use('assetService'), - walletAddressCache: await deps.use('walletAddressCache') + walletAddressCache: await deps.use('walletAddressCache'), + tenantSettingService: await deps.use('tenantSettingService') }) }) container.singleton('spspRoutes', async (deps) => { @@ -331,7 +471,9 @@ export function initIocContainer( config: await deps.use('config'), logger: await deps.use('logger'), incomingPaymentService: await deps.use('incomingPaymentService'), - streamCredentialsService: await deps.use('streamCredentialsService') + paymentMethodProviderService: await deps.use( + 'paymentMethodProviderService' + ) }) }) container.singleton('walletAddressRoutes', async (deps) => { @@ -349,24 +491,24 @@ export function initIocContainer( }) }) container.singleton('streamCredentialsService', async (deps) => { - const config = await deps.use('config') return await createStreamCredentialsService({ logger: await deps.use('logger'), - openPaymentsUrl: config.openPaymentsUrl, - streamServer: await deps.use('streamServer') + config: await deps.use('config') }) }) container.singleton('receiverService', async (deps) => { return await createReceiverService({ logger: await deps.use('logger'), config: await deps.use('config'), - streamCredentialsService: await deps.use('streamCredentialsService'), incomingPaymentService: await deps.use('incomingPaymentService'), walletAddressService: await deps.use('walletAddressService'), remoteIncomingPaymentService: await deps.use( 'remoteIncomingPaymentService' ), - telemetry: await deps.use('telemetry') + telemetry: await deps.use('telemetry'), + paymentMethodProviderService: await deps.use( + 'paymentMethodProviderService' + ) }) }) @@ -381,15 +523,16 @@ export function initIocContainer( const config = await deps.use('config') return await createConnectorService({ logger: await deps.use('logger'), + config: await deps.use('config'), redis: await deps.use('redis'), accountingService: await deps.use('accountingService'), walletAddressService: await deps.use('walletAddressService'), incomingPaymentService: await deps.use('incomingPaymentService'), peerService: await deps.use('peerService'), ratesService: await deps.use('ratesService'), - streamServer: await deps.use('streamServer'), ilpAddress: config.ilpAddress, - telemetry: await deps.use('telemetry') + telemetry: await deps.use('telemetry'), + tenantSettingService: await deps.use('tenantSettingService') }) }) @@ -654,6 +797,11 @@ export const start = async ( Model.knex(knex) + // Update Operator Tenant from config + const tenantService = await container.use('tenantService') + const error = await tenantService.updateOperatorApiSecretFromConfig() + if (error) throw error + await app.boot() await app.startAdminServer(config.adminPort) logger.info(`Admin listening on ${app.getAdminPort()}`) diff --git a/packages/backend/src/middleware/tenant/index.test.ts b/packages/backend/src/middleware/tenant/index.test.ts new file mode 100644 index 0000000000..38d1288313 --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.test.ts @@ -0,0 +1,13 @@ +import { tenantIdToProceed } from './index' + +describe('Set For Tenant', (): void => { + test('test tenant id to proceed', async (): Promise => { + const sig = 'sig' + const tenantId = 'tenantId' + expect(tenantIdToProceed(false, sig)).toBe(sig) + expect(tenantIdToProceed(false, sig, tenantId)).toBeUndefined() + expect(tenantIdToProceed(false, sig, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig, tenantId)).toBe(tenantId) + }) +}) diff --git a/packages/backend/src/middleware/tenant/index.ts b/packages/backend/src/middleware/tenant/index.ts new file mode 100644 index 0000000000..a90f75670b --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.ts @@ -0,0 +1,48 @@ +import { ForTenantIdContext, TenantedApolloContext } from '../../app' + +type Request = () => Promise + +interface TenantValidateMiddlewareArgs { + deps: { context: TenantedApolloContext } + tenantIdInput: string | undefined + next: Request +} + +export async function validateTenantMiddleware( + args: TenantValidateMiddlewareArgs +): ReturnType { + const { + deps: { context }, + tenantIdInput, + next + } = args + ;(context as ForTenantIdContext).forTenantId = tenantIdToProceed( + context.isOperator, + context.tenant.id, + tenantIdInput + ) + return next() +} + +/** + * The tenantId to use will be determined as follows: + * - When an operator and the {tenantId} is present, return {tenantId} + * - When an operator and {tenantId} is not present, return {signatureTenantId} + * - When NOT an operator and {tenantId} is present, but does not match {signatureTenantId}, return {undefined} + * - Otherwise return {signatureTenantId} + * + * @param isOperator is operator + * @param signatureTenantId the signature tenantId + * @param tenantId the intended tenantId + */ +export function tenantIdToProceed( + isOperator: boolean, + signatureTenantId: string, + tenantId?: string +): string | undefined { + if (isOperator && tenantId) return tenantId + else if (isOperator) return signatureTenantId + return tenantId && tenantId !== signatureTenantId + ? undefined + : signatureTenantId +} diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 98e095e8a1..7038afb51a 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -78,7 +78,9 @@ describe('Auth Middleware', (): void => { Authorization: `GNAP ${token}` } }, - walletAddress: await createWalletAddress(deps) + walletAddress: await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) ctx.container = deps }) diff --git a/packages/backend/src/open_payments/authServer/service.test.ts b/packages/backend/src/open_payments/authServer/service.test.ts index 1fd758a204..9b4a4af00a 100644 --- a/packages/backend/src/open_payments/authServer/service.test.ts +++ b/packages/backend/src/open_payments/authServer/service.test.ts @@ -24,7 +24,7 @@ describe('Auth Server Service', (): void => { }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/open_payments/grant/service.test.ts b/packages/backend/src/open_payments/grant/service.test.ts index eb10c65d71..fe7a0ba84e 100644 --- a/packages/backend/src/open_payments/grant/service.test.ts +++ b/packages/backend/src/open_payments/grant/service.test.ts @@ -46,7 +46,7 @@ describe('Grant Service', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/open_payments/payment/combined/model.ts b/packages/backend/src/open_payments/payment/combined/model.ts index 246292b888..4b307c1fc8 100644 --- a/packages/backend/src/open_payments/payment/combined/model.ts +++ b/packages/backend/src/open_payments/payment/combined/model.ts @@ -21,6 +21,7 @@ export class CombinedPayment extends PaginationModel { public state!: OutgoingPaymentState | IncomingPaymentState public walletAddressId!: string public metadata?: Record + public tenantId!: string public client?: string public createdAt!: Date public updatedAt!: Date diff --git a/packages/backend/src/open_payments/payment/combined/service.test.ts b/packages/backend/src/open_payments/payment/combined/service.test.ts index a035990b14..73efef5b28 100644 --- a/packages/backend/src/open_payments/payment/combined/service.test.ts +++ b/packages/backend/src/open_payments/payment/combined/service.test.ts @@ -4,7 +4,6 @@ import { TestContainer, createTestApp } from '../../../tests/app' import { initIocContainer } from '../../..' import { Config, IAppConfig } from '../../../config/app' import { CombinedPaymentService } from './service' -import { Knex } from 'knex' import { truncateTables } from '../../../tests/tableManager' import { getPageTests } from '../../../shared/baseModel.test' import { createOutgoingPayment } from '../../../tests/outgoingPayment' @@ -26,8 +25,8 @@ describe('Combined Payment Service', (): void => { let deps: IocContract let config: IAppConfig let appContainer: TestContainer - let knex: Knex let combinedPaymentService: CombinedPaymentService + let tenantId: string let sendAsset: Asset let sendWalletAddressId: string let receiveAsset: Asset @@ -37,23 +36,27 @@ describe('Combined Payment Service', (): void => { deps = await initIocContainer(Config) config = await deps.use('config') appContainer = await createTestApp(deps) - knex = appContainer.knex combinedPaymentService = await deps.use('combinedPaymentService') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { sendAsset = await createAsset(deps) receiveAsset = await createAsset(deps) sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, + assetId: sendAsset.id + }) ).id receiveWalletAddress = await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, assetId: receiveAsset.id }) }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -62,11 +65,13 @@ describe('Combined Payment Service', (): void => { async function setupPayments(deps: IocContract) { const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiveWalletAddress.id + walletAddressId: receiveWalletAddress.id, + tenantId: Config.operatorTenantId }) const receiverUrl = incomingPayment.getUrl(config.openPaymentsUrl) const outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: sendWalletAddressId, method: 'ilp', receiver: receiverUrl, @@ -137,5 +142,19 @@ describe('Combined Payment Service', (): void => { toCombinedPayment(PaymentType.Outgoing, outgoingPayment) ) }) + + test('can filter by tenantId', async (): Promise => { + await setupPayments(deps) + await expect( + combinedPaymentService.getPage({ + tenantId: crypto.randomUUID() + }) + ).resolves.toHaveLength(0) + await expect( + combinedPaymentService.getPage({ + tenantId: Config.operatorTenantId + }) + ).resolves.toHaveLength(2) + }) }) }) diff --git a/packages/backend/src/open_payments/payment/combined/service.ts b/packages/backend/src/open_payments/payment/combined/service.ts index 74a1ced361..566206838f 100644 --- a/packages/backend/src/open_payments/payment/combined/service.ts +++ b/packages/backend/src/open_payments/payment/combined/service.ts @@ -14,6 +14,7 @@ interface GetPageOptions { pagination?: Pagination filter?: CombinedPaymentFilter sortOrder?: SortOrder + tenantId?: string } export interface CombinedPaymentService { @@ -43,10 +44,14 @@ async function getCombinedPaymentsPage( deps: ServiceDependencies, options?: GetPageOptions ) { - const { filter, pagination, sortOrder } = options ?? {} + const { filter, pagination, sortOrder, tenantId } = options ?? {} const query = CombinedPayment.query(deps.knex) + if (tenantId) { + query.where('tenantId', tenantId) + } + if (filter?.walletAddressId?.in && filter.walletAddressId.in.length) { query.whereIn('walletAddressId', filter.walletAddressId.in) } diff --git a/packages/backend/src/open_payments/payment/incoming/model.test.ts b/packages/backend/src/open_payments/payment/incoming/model.test.ts index 63719d2531..2654c4ae33 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -6,7 +6,6 @@ import { AppServices } from '../../../app' import { createIncomingPayment } from '../../../tests/incomingPayment' import { createWalletAddress } from '../../../tests/walletAddress' import { truncateTables } from '../../../tests/tableManager' -import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' import { serializeAmount } from '../../amount' import { IlpAddress } from 'ilp-packet' import { @@ -17,6 +16,7 @@ import { IncomingPaymentEventError } from './model' import { WalletAddress } from '../../wallet_address/model' +import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service' describe('Models', (): void => { let deps: IocContract @@ -31,7 +31,7 @@ describe('Models', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -44,11 +44,14 @@ describe('Models', (): void => { let incomingPayment: IncomingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) baseUrl = config.openPaymentsUrl incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id, - metadata: { description: 'my payment' } + metadata: { description: 'my payment' }, + tenantId: walletAddress.tenantId }) }) @@ -60,8 +63,8 @@ describe('Models', (): void => { walletAddress ) ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${incomingPayment.tenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.address, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), incomingAmount: incomingPayment.incomingAmount @@ -76,20 +79,23 @@ describe('Models', (): void => { describe('toOpenPaymentsTypeWithMethods', () => { test('returns incoming payment with payment methods', async () => { - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } + const paymentMethods: OpenPaymentsPaymentMethod[] = [ + { + type: 'ilp', + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: '' + } + ] expect( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + paymentMethods ) ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${incomingPayment.tenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.address, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), incomingAmount: incomingPayment.incomingAmount @@ -108,46 +114,28 @@ describe('Models', (): void => { }) }) - test('returns incoming payment with empty methods when stream credentials are undefined', async () => { - expect( - incomingPayment.toOpenPaymentsTypeWithMethods( - config.openPaymentsUrl, - walletAddress - ) - ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, - completed: incomingPayment.completed, - receivedAmount: serializeAmount(incomingPayment.receivedAmount), - incomingAmount: incomingPayment.incomingAmount - ? serializeAmount(incomingPayment.incomingAmount) - : undefined, - expiresAt: incomingPayment.expiresAt.toISOString(), - metadata: incomingPayment.metadata ?? undefined, - createdAt: incomingPayment.createdAt.toISOString(), - methods: [] - }) - }) - test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( 'returns incoming payment with existing methods if payment state is %s', async (paymentState): Promise => { incomingPayment.state = paymentState - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } + const paymentMethods: OpenPaymentsPaymentMethod[] = [ + { + type: 'ilp', + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: '' + } + ] expect( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + paymentMethods ) ).toMatchObject({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${incomingPayment.tenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, + walletAddress: walletAddress.address, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), incomingAmount: incomingPayment.incomingAmount @@ -159,7 +147,7 @@ describe('Models', (): void => { methods: [ expect.objectContaining({ type: 'ilp', - ilpAddress: streamCredentials.ilpAddress, + ilpAddress: paymentMethods[0].ilpAddress, sharedSecret: expect.any(String) }) ] diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index f72c44d34c..6e1d050994 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -1,7 +1,6 @@ import { Model, QueryContext } from 'objection' import { Amount, AmountJSON, serializeAmount } from '../../amount' -import { IlpStreamCredentials } from '../../../payment-method/ilp/stream-credentials/service' import { WalletAddress, WalletAddressSubresource @@ -9,12 +8,12 @@ import { import { Asset } from '../../../asset/model' import { LiquidityAccount, OnCreditOptions } from '../../../accounting/service' import { ConnectorAccount } from '../../../payment-method/ilp/connector/core/rafiki' -import { WebhookEvent } from '../../../webhook/model' +import { WebhookEvent } from '../../../webhook/event/model' import { IncomingPayment as OpenPaymentsIncomingPayment, IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethod } from '@interledger/open-payments' -import base64url from 'base64url' +import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service' export enum IncomingPaymentEventType { IncomingPaymentCreated = 'incoming_payment.created', @@ -109,6 +108,7 @@ export class IncomingPayment private incomingAmountValue?: bigint | null private receivedAmountValue?: bigint + public readonly tenantId!: string public get completed(): boolean { return this.state === IncomingPaymentState.Completed @@ -143,7 +143,7 @@ export class IncomingPayment public getUrl(resourceServerUrl: string): string { resourceServerUrl = resourceServerUrl.replace(/\/+$/, '') - return `${resourceServerUrl}${IncomingPayment.urlPath}/${this.id}` + return `${resourceServerUrl}/${this.tenantId}${IncomingPayment.urlPath}/${this.id}` } public async onCredit({ @@ -223,7 +223,7 @@ export class IncomingPayment ): OpenPaymentsIncomingPayment { return { id: this.getUrl(resourceServerUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, incomingAmount: this.incomingAmount ? serializeAmount(this.incomingAmount) : undefined, @@ -238,19 +238,11 @@ export class IncomingPayment public toOpenPaymentsTypeWithMethods( resourceServerUrl: string, walletAddress: WalletAddress, - ilpStreamCredentials?: IlpStreamCredentials + paymentMethods: OpenPaymentsPaymentMethod[] ): OpenPaymentsIncomingPaymentWithPaymentMethod { return { ...this.toOpenPaymentsType(resourceServerUrl, walletAddress), - methods: !ilpStreamCredentials - ? [] - : [ - { - type: 'ilp', - ilpAddress: ilpStreamCredentials.ilpAddress, - sharedSecret: base64url(ilpStreamCredentials.sharedSecret) - } - ] + methods: paymentMethods } } diff --git a/packages/backend/src/open_payments/payment/incoming/routes.test.ts b/packages/backend/src/open_payments/payment/incoming/routes.test.ts index 46b76bb2a4..090f2e09dc 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -23,6 +23,7 @@ import { Asset } from '../../../asset/model' import { IncomingPaymentError, errorToHTTPCode, errorToMessage } from './errors' import { IncomingPaymentService } from './service' import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' +import { PaymentMethodProviderService } from '../../../payment-method/provider/service' describe('Incoming Payment Routes', (): void => { let deps: IocContract @@ -30,6 +31,8 @@ describe('Incoming Payment Routes', (): void => { let config: IAppConfig let incomingPaymentRoutes: IncomingPaymentRoutes let incomingPaymentService: IncomingPaymentService + let paymentMethodProviderService: PaymentMethodProviderService + let tenantId: string beforeAll(async (): Promise => { config = Config @@ -37,6 +40,7 @@ describe('Incoming Payment Routes', (): void => { appContainer = await createTestApp(deps) const { resourceServerSpec } = await deps.use('openApi') jestOpenAPI(resourceServerSpec) + tenantId = Config.operatorTenantId }) let asset: Asset @@ -50,10 +54,14 @@ describe('Incoming Payment Routes', (): void => { config = await deps.use('config') incomingPaymentRoutes = await deps.use('incomingPaymentRoutes') incomingPaymentService = await deps.use('incomingPaymentService') + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) expiresAt = new Date(Date.now() + 30_000) asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) baseUrl = config.openPaymentsUrl @@ -69,7 +77,7 @@ describe('Incoming Payment Routes', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -85,15 +93,22 @@ describe('Incoming Payment Routes', (): void => { client, expiresAt, incomingAmount, - metadata + metadata, + tenantId }), - get: (ctx) => - incomingPaymentRoutes.get(ctx as ReadContextWithAuthenticatedStatus), + get: (ctx) => { + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) + return incomingPaymentRoutes.get( + ctx as ReadContextWithAuthenticatedStatus + ) + }, getBody: (incomingPayment, list) => { const response: Partial = { id: incomingPayment.getUrl(config.openPaymentsUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, completed: false, incomingAmount: incomingPayment.incomingAmount && @@ -105,15 +120,7 @@ describe('Incoming Payment Routes', (): void => { } if (!list) { - response.methods = [ - { - type: 'ilp', - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(String) - } - ] + response.methods = [] } return response }, @@ -126,9 +133,12 @@ describe('Incoming Payment Routes', (): void => { test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( 'returns incoming payment with empty methods if payment state is %s', async (paymentState): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) await incomingPayment.$query().update({ state: paymentState }) @@ -149,7 +159,39 @@ describe('Incoming Payment Routes', (): void => { expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toMatchObject({ methods: [] }) } - ) + ), + test('by tenantId', async () => { + const walletAddress = await createWalletAddress(deps, { + tenantId + }) + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId + }) + + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' }, + method: 'GET', + url: `/incoming-payments/${incomingPayment.id}` + }, + params: { + id: incomingPayment.id, + tenantId + }, + walletAddress + }) + + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) + await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() + + expect(ctx.response).toSatisfyApiSpec() + expect(ctx.body).toMatchObject({ + id: `${baseUrl}/${tenantId}/incoming-payments/${incomingPayment.id}` + }) + }) }) describe('create', (): void => { @@ -168,7 +210,10 @@ describe('Incoming Payment Routes', (): void => { async (error): Promise => { const ctx = setup>({ reqOpts: { body: {} }, - walletAddress + walletAddress, + params: { + tenantId + } }) const createSpy = jest .spyOn(incomingPaymentService, 'create') @@ -178,7 +223,8 @@ describe('Incoming Payment Routes', (): void => { status: errorToHTTPCode[error] }) expect(createSpy).toHaveBeenCalledWith({ - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) } ) @@ -196,6 +242,9 @@ describe('Incoming Payment Routes', (): void => { expiresAt }): Promise => { const ctx = setup>({ + params: { + tenantId + }, reqOpts: { body: { incomingAmount: incomingAmount ? amount : undefined, @@ -210,13 +259,17 @@ describe('Incoming Payment Routes', (): void => { }) const incomingPaymentService = await deps.use('incomingPaymentService') const createSpy = jest.spyOn(incomingPaymentService, 'create') + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect(incomingPaymentRoutes.create(ctx)).resolves.toBeUndefined() expect(createSpy).toHaveBeenCalledWith({ walletAddressId: walletAddress.id, incomingAmount: incomingAmount ? parseAmount(amount) : undefined, metadata, expiresAt: expiresAt ? new Date(expiresAt) : undefined, - client + client, + tenantId }) expect(ctx.response).toSatisfyApiSpec() const incomingPaymentId = ( @@ -226,8 +279,8 @@ describe('Incoming Payment Routes', (): void => { .pop() expect(ctx.response.body).toEqual({ - id: `${baseUrl}/incoming-payments/${incomingPaymentId}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${tenantId}/incoming-payments/${incomingPaymentId}`, + walletAddress: walletAddress.address, incomingAmount: incomingAmount ? amount : undefined, expiresAt: expiresAt || expect.any(String), createdAt: expect.any(String), @@ -238,15 +291,7 @@ describe('Incoming Payment Routes', (): void => { }, metadata, completed: false, - methods: [ - { - type: 'ilp', - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(String) - } - ] + methods: [] }) } ) @@ -259,7 +304,8 @@ describe('Incoming Payment Routes', (): void => { walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) }) test('returns 200 with an updated open payments incoming payment', async (): Promise => { @@ -270,7 +316,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}/complete` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -278,8 +325,8 @@ describe('Incoming Payment Routes', (): void => { await expect(incomingPaymentRoutes.complete(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ - id: incomingPayment.getUrl(config.openPaymentsUrl), - walletAddress: walletAddress.url, + id: incomingPayment.getUrl(baseUrl), + walletAddress: walletAddress.address, incomingAmount: { value: '123', assetCode: asset.code, @@ -303,7 +350,8 @@ describe('Incoming Payment Routes', (): void => { walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) const ctx = setup({ @@ -313,7 +361,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -322,7 +371,7 @@ describe('Incoming Payment Routes', (): void => { await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ - authServer: config.authServerGrantUrl, + authServer: config.authServerGrantUrl + '/' + incomingPayment.tenantId, receivedAmount: { value: '0', assetCode: asset.code, diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index 4d5e06e348..00152b9126 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -15,15 +15,15 @@ import { } from './errors' import { AmountJSON, parseAmount } from '../../amount' import { listSubresource } from '../../wallet_address/routes' -import { StreamCredentialsService } from '../../../payment-method/ilp/stream-credentials/service' import { AccessAction } from '@interledger/open-payments' import { OpenPaymentsServerRouteError } from '../../route-errors' +import { PaymentMethodProviderService } from '../../../payment-method/provider/service' interface ServiceDependencies { config: IAppConfig logger: Logger incomingPaymentService: IncomingPaymentService - streamCredentialsService: StreamCredentialsService + paymentMethodProviderService: PaymentMethodProviderService } export type ReadContextWithAuthenticatedStatus = ReadContext & @@ -70,7 +70,8 @@ async function getIncomingPaymentPublic( ) { const incomingPayment = await deps.incomingPaymentService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!incomingPayment) { @@ -84,7 +85,7 @@ async function getIncomingPaymentPublic( } ctx.body = incomingPayment.toPublicOpenPaymentsType( - deps.config.authServerGrantUrl + `${deps.config.authServerGrantUrl}/${incomingPayment?.walletAddress?.tenantId}` ) } @@ -94,7 +95,8 @@ async function getIncomingPaymentPrivate( ): Promise { const incomingPayment = await deps.incomingPaymentService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!incomingPayment) { @@ -107,14 +109,14 @@ async function getIncomingPaymentPrivate( ) } - const streamCredentials = incomingPayment.isExpiredOrComplete() - ? undefined - : deps.streamCredentialsService.get(incomingPayment) + const paymentMethods = incomingPayment.isExpiredOrComplete() + ? [] + : await deps.paymentMethodProviderService.getPaymentMethods(incomingPayment) ctx.body = incomingPayment.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, ctx.walletAddress, - streamCredentials + paymentMethods ) } @@ -141,7 +143,8 @@ async function createIncomingPayment( client: ctx.client, metadata: body.metadata, expiresAt, - incomingAmount: body.incomingAmount && parseAmount(body.incomingAmount) + incomingAmount: body.incomingAmount && parseAmount(body.incomingAmount), + tenantId: ctx.params.tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -150,15 +153,16 @@ async function createIncomingPayment( errorToMessage[incomingPaymentOrError] ) } + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods( + incomingPaymentOrError + ) ctx.status = 201 - const streamCredentials = deps.streamCredentialsService.get( - incomingPaymentOrError - ) ctx.body = incomingPaymentOrError.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, ctx.walletAddress, - streamCredentials + paymentMethods ) } @@ -167,7 +171,8 @@ async function completeIncomingPayment( ctx: CompleteContext ): Promise { const incomingPaymentOrError = await deps.incomingPaymentService.complete( - ctx.params.id + ctx.params.id, + ctx.params.tenantId ) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -189,7 +194,13 @@ async function listIncomingPayments( ): Promise { await listSubresource({ ctx, - getWalletAddressPage: deps.incomingPaymentService.getWalletAddressPage, + getWalletAddressPage: async ({ walletAddressId, pagination, client }) => + deps.incomingPaymentService.getWalletAddressPage({ + walletAddressId, + pagination, + client, + tenantId: ctx.params.tenantId + }), toBody: (payment) => payment.toOpenPaymentsType(deps.config.openPaymentsUrl, ctx.walletAddress) }) diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index 6826f46b00..a59a46cc4d 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -26,7 +26,8 @@ import { Amount } from '../../amount' import { getTests } from '../../wallet_address/model.test' import { WalletAddress } from '../../wallet_address/model' import { withConfigOverride } from '../../../tests/helpers' -import { sleep } from '../../../shared/utils' +import { poll } from '../../../shared/utils' +import { createTenant } from '../../../tests/tenant' describe('Incoming Payment Service', (): void => { let deps: IocContract @@ -38,6 +39,7 @@ describe('Incoming Payment Service', (): void => { let accountingService: AccountingService let asset: Asset let config: IAppConfig + let tenantId: string beforeAll(async (): Promise => { deps = initIocContainer({ @@ -49,18 +51,22 @@ describe('Incoming Payment Service', (): void => { knex = appContainer.knex incomingPaymentService = await deps.use('incomingPaymentService') config = await deps.use('config') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { asset = await createAsset(deps) - const address = await createWalletAddress(deps, { assetId: asset.id }) + const address = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, + assetId: asset.id + }) walletAddressId = address.id - client = address.url + client = address.address }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -79,13 +85,18 @@ describe('Incoming Payment Service', (): void => { approvedAt?: Date cancelledAt?: Date }) { - await sleep(50) - const incomingPaymentEvent = await IncomingPaymentEvent.query( - knex - ).findOne({ - type: IncomingPaymentEventType.IncomingPaymentCreated + const incomingPaymentEvent = await poll({ + request: async () => + IncomingPaymentEvent.query(knex).findOne({ + type: IncomingPaymentEventType.IncomingPaymentCreated + }), + pollingFrequencyMs: 10, + timeoutMs: + actionableIncomingPaymentConfigOverride() + .incomingPaymentCreatedPollTimeout }) - assert.ok(!!incomingPaymentEvent) + + assert.ok(incomingPaymentEvent) await IncomingPayment.query(knex) .findById(incomingPaymentEvent.incomingPaymentId as string) .patch(options) @@ -101,7 +112,8 @@ describe('Incoming Payment Service', (): void => { const options = { client: faker.internet.url({ appendSlash: false }), incomingAmount: true, - expiresAt: new Date(Date.now() + 30_000) + expiresAt: new Date(Date.now() + 30_000), + tenantId } return incomingPaymentService.create({ @@ -171,9 +183,9 @@ describe('Incoming Payment Service', (): void => { describe('approveIncomingPayment', (): void => { it('should return UnknownPayment error if payment does not exist', async (): Promise => { - expect(incomingPaymentService.approve(uuid())).resolves.toBe( - IncomingPaymentError.UnknownPayment - ) + expect( + incomingPaymentService.approve(uuid(), Config.operatorTenantId) + ).resolves.toBe(IncomingPaymentError.UnknownPayment) }) it('should not approve already cancelled incoming payment', async (): Promise => { @@ -185,7 +197,8 @@ describe('Incoming Payment Service', (): void => { .patch({ cancelledAt: new Date() }) const response = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) expect(response).toBe(IncomingPaymentError.AlreadyActioned) }) @@ -200,7 +213,8 @@ describe('Incoming Payment Service', (): void => { .patch({ approvedAt }) const approvedPayment = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(approvedPayment)) @@ -218,7 +232,8 @@ describe('Incoming Payment Service', (): void => { .patch({ state: IncomingPaymentState.Pending }) const approvedIncomingPayment = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(approvedIncomingPayment)) expect(approvedIncomingPayment.id).toBe(incomingPayment.id) @@ -230,9 +245,9 @@ describe('Incoming Payment Service', (): void => { describe('cancelIncomingPayment', (): void => { it('should return UnknownPayment error if payment does not exist', async (): Promise => { - expect(incomingPaymentService.cancel(uuid())).resolves.toBe( - IncomingPaymentError.UnknownPayment - ) + expect( + incomingPaymentService.cancel(uuid(), Config.operatorTenantId) + ).resolves.toBe(IncomingPaymentError.UnknownPayment) }) it('should not cancel already approved incoming payment', async (): Promise => { @@ -243,7 +258,10 @@ describe('Incoming Payment Service', (): void => { .findOne({ id: incomingPayment.id }) .patch({ approvedAt: new Date() }) - const response = await incomingPaymentService.cancel(incomingPayment.id) + const response = await incomingPaymentService.cancel( + incomingPayment.id, + Config.operatorTenantId + ) expect(response).toBe(IncomingPaymentError.AlreadyActioned) }) @@ -257,7 +275,8 @@ describe('Incoming Payment Service', (): void => { .patch({ cancelledAt }) const cancelledPayment = await incomingPaymentService.cancel( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(cancelledPayment)) @@ -275,7 +294,8 @@ describe('Incoming Payment Service', (): void => { .patch({ state: IncomingPaymentState.Pending }) const canceledIncomingPayment = await incomingPaymentService.cancel( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(canceledIncomingPayment)) expect(canceledIncomingPayment.id).toBe(incomingPayment.id) @@ -297,9 +317,9 @@ describe('Incoming Payment Service', (): void => { }) test.each` - client | incomingAmount | expiresAt | metadata - ${undefined} | ${false} | ${undefined} | ${undefined} - ${faker.internet.url({ appendSlash: false })} | ${true} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123', items: [1, 2, 3] }} + isOperator | client | incomingAmount | expiresAt | metadata + ${false} | ${undefined} | ${false} | ${undefined} | ${undefined} + ${true} | ${faker.internet.url({ appendSlash: false })} | ${true} | ${new Date(Date.now() + 30_000)} | ${{ description: 'Test incoming payment', externalRef: '#123', items: [1, 2, 3] }} `('An incoming payment can be created', async (options): Promise => { await expect( IncomingPaymentEvent.query(knex).where({ @@ -307,24 +327,52 @@ describe('Incoming Payment Service', (): void => { }) ).resolves.toHaveLength(0) options.client = client + const tenantId = options.isOperator + ? Config.operatorTenantId + : (await createTenant(deps)).id + const testAsset = options.isOperator + ? asset + : await createAsset(deps, { tenantId }) + const incomingPayment = await incomingPaymentService.create({ - walletAddressId, + walletAddressId: options.isOperator + ? walletAddressId + : ( + await createWalletAddress(deps, { + tenantId, + assetId: testAsset.id + }) + ).id, ...options, - incomingAmount: options.incomingAmount ? amount : undefined + incomingAmount: options.incomingAmount ? amount : undefined, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPayment)) expect(incomingPayment).toMatchObject({ id: incomingPayment.id, client, - asset, + asset: testAsset, processAt: new Date(incomingPayment.expiresAt.getTime()), metadata: options.metadata ?? null }) - await expect( - IncomingPaymentEvent.query(knex).where({ + const events = await IncomingPaymentEvent.query(knex) + .where({ type: IncomingPaymentEventType.IncomingPaymentCreated }) - ).resolves.toHaveLength(1) + .withGraphFetched('webhooks') + expect(events).toHaveLength(1) + assert.ok(events[0].webhooks) + expect(events[0].webhooks).toHaveLength(1) + expect(events[0].webhooks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventId: events[0].id, + recipientTenantId: events[0].tenantId, + attempts: 0, + processAt: expect.any(Date) + }) + ]) + ) }) test('Cannot create incoming payment for nonexistent wallet address', async (): Promise => { @@ -340,7 +388,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.UnknownWalletAddress) }) @@ -362,7 +411,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -377,7 +427,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -395,7 +446,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -410,7 +462,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -428,7 +481,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -451,7 +505,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InactiveWalletAddress) }) @@ -471,7 +526,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -488,7 +544,8 @@ describe('Incoming Payment Service', (): void => { } payment = (await incomingPaymentService.create({ walletAddressId, - incomingAmount: amount + incomingAmount: amount, + tenantId })) as IncomingPayment assert.ok(!isIncomingPaymentError(payment)) }) @@ -502,6 +559,7 @@ describe('Incoming Payment Service', (): void => { async ({ metadata }): Promise => { const incomingPayment = await incomingPaymentService.update({ id: payment.id, + tenantId: Config.operatorTenantId, metadata }) assert.ok(!isIncomingPaymentError(incomingPayment)) @@ -516,6 +574,7 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.update({ id: uuid(), + tenantId: Config.operatorTenantId, metadata: { description: 'Test incoming payment', externalRef: '#123' @@ -540,7 +599,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }), get: (options) => incomingPaymentService.get(options), list: (options) => incomingPaymentService.getWalletAddressPage(options) @@ -562,7 +622,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) incomingPayment = incomingPaymentOrError @@ -625,7 +686,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) const incomingPaymentId = incomingPaymentOrError.id @@ -654,7 +716,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -691,7 +754,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) jest.useFakeTimers() jest.setSystemTime(incomingPayment.expiresAt) @@ -728,7 +792,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -780,14 +845,25 @@ describe('Incoming Payment Service', (): void => { await expect(incomingPaymentService.processNext()).resolves.toBe( incomingPayment.id ) - await expect( - IncomingPaymentEvent.query(knex).where({ + const events = await IncomingPaymentEvent.query(knex) + .where({ incomingPaymentId: incomingPayment.id, type: eventType, withdrawalAccountId: incomingPayment.id, withdrawalAmount: amountReceived }) - ).resolves.toHaveLength(1) + .withGraphFetched('webhooks') + expect(events).toHaveLength(1) + assert.ok(events[0].webhooks) + expect(events[0].webhooks).toHaveLength(1) + expect(events[0].webhooks[0]).toMatchObject( + expect.objectContaining({ + eventId: events[0].id, + recipientTenantId: events[0].tenantId, + attempts: 0, + processAt: expect.any(Date) + }) + ) await expect( incomingPaymentService.get({ id: incomingPayment.id @@ -816,7 +892,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) }) test('updates state of pending incoming payment to complete', async (): Promise => { @@ -824,11 +901,15 @@ describe('Incoming Payment Service', (): void => { jest.useFakeTimers({ now }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, - processAt: now + processAt: now, + tenantId: Config.operatorTenantId }) await expect( incomingPaymentService.get({ @@ -838,12 +919,21 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Completed, processAt: now }) + await expect( + incomingPaymentService.get({ + id: incomingPayment.id, + tenantId: Config.operatorTenantId + }) + ).resolves.toMatchObject({ + state: IncomingPaymentState.Completed, + processAt: now + }) }) test('fails to complete unknown payment', async (): Promise => { - await expect(incomingPaymentService.complete(uuid())).resolves.toEqual( - IncomingPaymentError.UnknownPayment - ) + await expect( + incomingPaymentService.complete(uuid(), Config.operatorTenantId) + ).resolves.toEqual(IncomingPaymentError.UnknownPayment) }) test('updates state of processing incoming payment to complete', async (): Promise => { @@ -861,7 +951,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Processing }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, @@ -899,7 +992,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Expired }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( incomingPaymentService.get({ @@ -922,7 +1018,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Completed }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( incomingPaymentService.get({ diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 57cc7d4d30..4dbb267b95 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -18,6 +18,7 @@ import { IncomingPaymentError } from './errors' import { IAppConfig } from '../../../config/app' import { poll } from '../../../shared/utils' import { AssetService } from '../../../asset/service' +import { finalizeWebhookRecipients } from '../../../webhook/service' export const POSITIVE_SLIPPAGE = BigInt(1) // First retry waits 10 seconds @@ -31,11 +32,13 @@ export interface CreateIncomingPaymentOptions { expiresAt?: Date incomingAmount?: Amount metadata?: Record + tenantId: string } export interface UpdateOptions { id: string metadata: Record + tenantId: string } export interface IncomingPaymentService @@ -44,9 +47,18 @@ export interface IncomingPaymentService options: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise - approve(id: string): Promise - cancel(id: string): Promise - complete(id: string): Promise + approve( + id: string, + tenantId: string + ): Promise + cancel( + id: string, + tenantId: string + ): Promise + complete( + id: string, + tenantId: string + ): Promise processNext(): Promise update( options: UpdateOptions @@ -74,9 +86,9 @@ export async function createIncomingPaymentService( return { get: (options) => getIncomingPayment(deps, options), create: (options, trx) => createIncomingPayment(deps, options, trx), - approve: (id) => approveIncomingPayment(deps, id), - cancel: (id) => cancelIncomingPayment(deps, id), - complete: (id) => completeIncomingPayment(deps, id), + approve: (id, tenantId) => approveIncomingPayment(deps, id, tenantId), + cancel: (id, tenantId) => cancelIncomingPayment(deps, id, tenantId), + complete: (id, tenantId) => completeIncomingPayment(deps, id, tenantId), getWalletAddressPage: (options) => getWalletAddressPage(deps, options), processNext: () => processNextIncomingPayment(deps), update: (options) => updateIncomingPayment(deps, options) @@ -108,7 +120,7 @@ async function updateIncomingPayment( ): Promise { const incomingPayment = await IncomingPayment.query( deps.knex - ).patchAndFetchById(options.id, { metadata: options.metadata }) + ).patchAndFetchById(options.id, options) if (incomingPayment) { const asset = await deps.assetService.get(incomingPayment.assetId) if (asset) incomingPayment.asset = asset @@ -130,7 +142,8 @@ async function createIncomingPayment( client, expiresAt, incomingAmount, - metadata + metadata, + tenantId }: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise { @@ -145,7 +158,10 @@ async function createIncomingPayment( if (incomingAmount && incomingAmount.value <= 0) { return IncomingPaymentError.InvalidAmount } - const walletAddress = await deps.walletAddressService.get(walletAddressId) + const walletAddress = await deps.walletAddressService.get( + walletAddressId, + tenantId + ) if (!walletAddress) { return IncomingPaymentError.UnknownWalletAddress } @@ -171,7 +187,8 @@ async function createIncomingPayment( incomingAmount, metadata, state: IncomingPaymentState.Pending, - processAt: expiresAt + processAt: expiresAt, + tenantId }) const asset = await deps.assetService.get(incomingPayment.assetId) @@ -181,10 +198,12 @@ async function createIncomingPayment( incomingPayment.walletAddressId ) - await IncomingPaymentEvent.query(trx || deps.knex).insert({ + await IncomingPaymentEvent.query(trx || deps.knex).insertGraph({ incomingPaymentId: incomingPayment.id, type: IncomingPaymentEventType.IncomingPaymentCreated, - data: incomingPayment.toData(0n) + data: incomingPayment.toData(0n), + tenantId: incomingPayment.tenantId, + webhooks: finalizeWebhookRecipients([incomingPayment.tenantId], deps.config) }) incomingPayment = await addReceivedAmount(deps, incomingPayment, BigInt(0)) @@ -341,7 +360,7 @@ async function handleDeactivated( : IncomingPaymentEventType.IncomingPaymentCompleted deps.logger.trace({ type }, 'creating incoming payment webhook event') - await IncomingPaymentEvent.query(deps.knex).insert({ + await IncomingPaymentEvent.query(deps.knex).insertGraph({ incomingPaymentId: incomingPayment.id, type, data: incomingPayment.toData(amountReceived), @@ -349,7 +368,12 @@ async function handleDeactivated( accountId: incomingPayment.id, assetId: incomingPayment.assetId, amount: amountReceived - } + }, + tenantId: incomingPayment.tenantId, + webhooks: finalizeWebhookRecipients( + [incomingPayment.tenantId], + deps.config + ) }) await incomingPayment.$query(deps.knex).patch({ @@ -364,7 +388,12 @@ async function getWalletAddressPage( deps: ServiceDependencies, options: ListOptions ): Promise { - const page = await IncomingPayment.query(deps.knex).list(options) + const pageQuery = IncomingPayment.query(deps.knex) + + if (options.tenantId) pageQuery.where('tenantId', options.tenantId) + + const page = await pageQuery.list(options) + for (const payment of page) { const asset = await deps.assetService.get(payment.assetId) if (asset) payment.asset = asset @@ -400,10 +429,13 @@ async function getWalletAddressPage( async function approveIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment @@ -437,10 +469,13 @@ async function approveIncomingPayment( async function cancelIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment @@ -474,10 +509,13 @@ async function cancelIncomingPayment( async function completeIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment const asset = await deps.assetService.get(payment.assetId) diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts index a971c3523a..89d06494eb 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.test.ts @@ -1,4 +1,3 @@ -import { Knex } from 'knex' import { v4 as uuid } from 'uuid' import { RemoteIncomingPaymentService } from './service' import { createTestApp, TestContainer } from '../../../tests/app' @@ -26,7 +25,6 @@ describe('Remote Incoming Payment Service', (): void => { let deps: IocContract let appContainer: TestContainer let remoteIncomingPaymentService: RemoteIncomingPaymentService - let knex: Knex let openPaymentsClient: OpenPaymentsClient let grantService: GrantService @@ -35,7 +33,6 @@ describe('Remote Incoming Payment Service', (): void => { appContainer = await createTestApp(deps) openPaymentsClient = await deps.use('openPaymentsClient') grantService = await deps.use('grantService') - knex = appContainer.knex remoteIncomingPaymentService = await deps.use( 'remoteIncomingPaymentService' ) @@ -43,7 +40,7 @@ describe('Remote Incoming Payment Service', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts index 39972a7ed4..40bf5ee8d4 100644 --- a/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts +++ b/packages/backend/src/open_payments/payment/outgoing/lifecycle.ts @@ -9,6 +9,7 @@ import { ServiceDependencies } from './service' import { Receiver } from '../../receiver/model' import { TransactionOrKnex } from 'objection' import { ValueType } from '@opentelemetry/api' +import { finalizeWebhookRecipients } from '../../../webhook/service' // "payment" is locked by the "deps.knex" transaction. export async function handleSending( @@ -122,6 +123,7 @@ export async function handleSending( 'transaction_fee_amounts', payment.sentAmount, payment.receiveAmount, + payment.tenantId, { description: 'Amount sent through the network as fees', valueType: ValueType.DOUBLE @@ -234,11 +236,13 @@ export async function sendWebhookEvent( } : undefined - await OutgoingPaymentEvent.query(trx || deps.knex).insert({ + await OutgoingPaymentEvent.query(trx || deps.knex).insertGraph({ outgoingPaymentId: payment.id, type, data: payment.toData({ amountSent, balance }), - withdrawal + withdrawal, + tenantId: payment.tenantId, + webhooks: finalizeWebhookRecipients([payment.tenantId], deps.config) }) stopTimer() } diff --git a/packages/backend/src/open_payments/payment/outgoing/model.test.ts b/packages/backend/src/open_payments/payment/outgoing/model.test.ts index f724192d6c..5c3538c13d 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.test.ts @@ -23,7 +23,7 @@ describe('Outgoing Payment Event Model', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 9d954fddc1..607482d32e 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -10,11 +10,12 @@ import { } from '../../wallet_address/model' import { Quote } from '../../quote/model' import { Amount, AmountJSON, serializeAmount } from '../../amount' -import { WebhookEvent } from '../../../webhook/model' +import { WebhookEvent } from '../../../webhook/event/model' import { OutgoingPayment as OpenPaymentsOutgoingPayment, OutgoingPaymentWithSpentAmounts } from '@interledger/open-payments' +import { Tenant } from '../../../tenants/model' export class OutgoingPaymentGrant extends DbErrors(Model) { public static get modelPaths(): string[] { @@ -108,7 +109,7 @@ export class OutgoingPayment public getUrl(resourceServerUrl: string): string { resourceServerUrl = resourceServerUrl.replace(/\/+$/, '') - return `${resourceServerUrl}${OutgoingPayment.urlPath}/${this.id}` + return `${resourceServerUrl}/${this.tenantId}${OutgoingPayment.urlPath}/${this.id}` } public get asset(): Asset { @@ -125,6 +126,8 @@ export class OutgoingPayment // Outgoing peer public peerId?: string + public tenantId!: string + static get relationMappings() { return { ...super.relationMappings, @@ -135,6 +138,14 @@ export class OutgoingPayment from: 'outgoingPayments.id', to: 'quotes.id' } + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant, + join: { + from: 'outgoingPayments.tenantId', + to: 'tenants.id' + } } } } @@ -199,7 +210,7 @@ export class OutgoingPayment ): OpenPaymentsOutgoingPayment { return { id: this.getUrl(resourceServerUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, quoteId: this.quote?.getUrl(resourceServerUrl) ?? undefined, receiveAmount: serializeAmount(this.receiveAmount), debitAmount: serializeAmount(this.debitAmount), diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index 242b000000..c1662b82b2 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -41,6 +41,7 @@ describe('Outgoing Payment Routes', (): void => { let outgoingPaymentService: OutgoingPaymentService let walletAddress: WalletAddress let baseUrl: string + let tenantId: string const receivingWalletAddress = `https://wallet.example/${uuid()}` @@ -51,6 +52,7 @@ describe('Outgoing Payment Routes', (): void => { }): Promise => { return await createOutgoingPayment(deps, { ...options, + tenantId: Config.operatorTenantId, walletAddressId: walletAddress.id, method: 'ilp', receiver: `${receivingWalletAddress}/incoming-payments/${uuid()}`, @@ -77,12 +79,16 @@ describe('Outgoing Payment Routes', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) - walletAddress = await createWalletAddress(deps, { assetId: asset.id }) + tenantId = Config.operatorTenantId + walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: asset.id + }) baseUrl = config.openPaymentsUrl }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -114,8 +120,8 @@ describe('Outgoing Payment Routes', (): void => { get: (ctx) => outgoingPaymentRoutes.get(ctx), getBody: (outgoingPayment) => { return { - id: `${baseUrl}/outgoing-payments/${outgoingPayment.id}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${tenantId}/outgoing-payments/${outgoingPayment.id}`, + walletAddress: walletAddress.address, receiver: outgoingPayment.receiver, quoteId: outgoingPayment.quote.getUrl(config.openPaymentsUrl), debitAmount: serializeAmount(outgoingPayment.debitAmount), @@ -133,7 +139,7 @@ describe('Outgoing Payment Routes', (): void => { type SetupContextOptions = UnionOmit< CreateOutgoingPaymentOptions, - 'walletAddressId' + 'walletAddressId' | 'tenantId' > describe('create', (): void => { @@ -148,6 +154,9 @@ describe('Outgoing Payment Routes', (): void => { url: `/outgoing-payments`, body: options }, + params: { + tenantId + }, walletAddress, client: options.client, grant: options.grant @@ -181,6 +190,7 @@ describe('Outgoing Payment Routes', (): void => { CreateOutgoingPaymentBaseOptions, 'walletAddressId' > = { + tenantId, client, grant, metadata @@ -188,7 +198,7 @@ describe('Outgoing Payment Routes', (): void => { if (createFrom === CreateFrom.Quote) { options = { ...options, - quoteId: `${baseUrl}/quotes/${payment.quote.id}` + quoteId: `${baseUrl}/${payment.quote.tenantId}/quotes/${payment.quote.id}` } as CreateFromQuote } else { assert(createFrom === CreateFrom.IncomingPayment) @@ -211,6 +221,7 @@ describe('Outgoing Payment Routes', (): void => { ).resolves.toBeUndefined() let expectedCreateOptions: CreateOutgoingPaymentBaseOptions = { + tenantId, walletAddressId: walletAddress.id, metadata, client, @@ -239,8 +250,8 @@ describe('Outgoing Payment Routes', (): void => { .split('/') .pop() expect(ctx.response.body).toMatchObject({ - id: `${baseUrl}/outgoing-payments/${outgoingPaymentId}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${tenantId}/outgoing-payments/${outgoingPaymentId}`, + walletAddress: walletAddress.address, receiver: payment.receiver, quoteId: 'quoteId' in options ? options.quoteId : expect.any(String), @@ -279,8 +290,9 @@ describe('Outgoing Payment Routes', (): void => { 'returns error on %s', async (error): Promise => { const quoteId = uuid() + const tenantId = Config.operatorTenantId const ctx = setup({ - quoteId: `${baseUrl}/quotes/${quoteId}` + quoteId: `${baseUrl}/${tenantId}/quotes/${quoteId}` }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -298,7 +310,8 @@ describe('Outgoing Payment Routes', (): void => { expect(createSpy).toHaveBeenCalledWith({ walletAddressId: walletAddress.id, - quoteId + quoteId, + tenantId }) } ) diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.ts b/packages/backend/src/open_payments/payment/outgoing/routes.ts index db15e01648..fd0829a839 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.ts @@ -54,6 +54,7 @@ async function getOutgoingPayment( ): Promise { const outgoingPayment = await deps.outgoingPaymentService.get({ id: ctx.params.id, + tenantId: ctx.params.tenantId, client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined }) @@ -98,6 +99,7 @@ async function createOutgoingPayment( ): Promise { const { body } = ctx.request const baseOptions: OutgoingPaymentCreateBaseOptions = { + tenantId: ctx.params.tenantId, walletAddressId: ctx.walletAddress.id, metadata: body.metadata, client: ctx.client, @@ -149,7 +151,13 @@ async function listOutgoingPayments( ): Promise { await listSubresource({ ctx, - getWalletAddressPage: deps.outgoingPaymentService.getWalletAddressPage, + getWalletAddressPage: async ({ walletAddressId, pagination, client }) => + deps.outgoingPaymentService.getWalletAddressPage({ + walletAddressId, + pagination, + client, + tenantId: ctx.params.tenantId + }), toBody: (payment) => outgoingPaymentToBody(deps, ctx.walletAddress, payment) }) } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 158ac43ada..6d95713473 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -54,6 +54,14 @@ import { TelemetryService } from '../../../telemetry/service' import { getPageTests } from '../../../shared/baseModel.test' import { Pagination, SortOrder } from '../../../shared/baseModel' import { ReceiverService } from '../../receiver/service' +import { WalletAddressService } from '../../wallet_address/service' +import { CreateOptions } from '../../../tenants/settings/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../../tests/tenantSettings' +import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service' +import { IlpAddress } from 'ilp-packet' describe('OutgoingPaymentService', (): void => { let deps: IocContract @@ -62,16 +70,17 @@ describe('OutgoingPaymentService', (): void => { let accountingService: AccountingService let paymentMethodHandlerService: PaymentMethodHandlerService let quoteService: QuoteService + let walletAddressService: WalletAddressService let telemetryService: TelemetryService let knex: Knex let assetId: string + let tenantId: string let walletAddressId: string let incomingPayment: IncomingPayment let receiverWalletAddress: MockWalletAddress let receiver: string let client: string let amtDelivered: bigint - let trx: Knex.Transaction let config: IAppConfig let receiverService: ReceiverService let receiverGet: typeof receiverService.get @@ -232,12 +241,25 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toEqual(incomingPaymentReceived) } if (withdrawAmount !== undefined && withdrawAmount > 0) { - await expect( - OutgoingPaymentEvent.query(knex).where({ + const events = await OutgoingPaymentEvent.query(knex) + .where({ withdrawalAccountId: payment.id, withdrawalAmount: withdrawAmount }) - ).resolves.toHaveLength(1) + .withGraphFetched('webhooks') + expect(events).toHaveLength(1) + + expect(events[0].webhooks).toHaveLength(1) + expect(events[0].webhooks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + recipientTenantId: payment.tenantId, + eventId: events[0].id, + processAt: expect.any(Date) + }) + ]) + ) } if (client !== undefined) { expect(payment.client).toEqual(client) @@ -245,15 +267,8 @@ describe('OutgoingPaymentService', (): void => { } beforeAll(async (): Promise => { - const exchangeRatesUrl = 'https://test.rates' - - mockRatesApi(exchangeRatesUrl, () => ({ - XRP: exchangeRate - })) - deps = await initIocContainer({ ...Config, - exchangeRatesUrl, enableTelemetry: true, localCacheDuration: 0 }) @@ -262,6 +277,7 @@ describe('OutgoingPaymentService', (): void => { accountingService = await deps.use('accountingService') paymentMethodHandlerService = await deps.use('paymentMethodHandlerService') quoteService = await deps.use('quoteService') + walletAddressService = await deps.use('walletAddressService') telemetryService = (await deps.use('telemetry'))! config = await deps.use('config') knex = appContainer.knex @@ -269,15 +285,30 @@ describe('OutgoingPaymentService', (): void => { }) beforeEach(async (): Promise => { - const { id: sendAssetId } = await createAsset(deps, asset) + tenantId = config.operatorTenantId + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + const tenantSetting = createTenantSettings(deps, createOptions) + const tenantExchangeRatesUrl = (await tenantSetting).value + mockRatesApi(tenantExchangeRatesUrl, () => ({ + XRP: exchangeRate + })) + + const { id: sendAssetId } = await createAsset(deps, { assetOptions: asset }) assetId = sendAssetId const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: sendAssetId }) walletAddressId = walletAddress.id - client = walletAddress.url - const { id: destinationAssetId } = await createAsset(deps, destinationAsset) + client = walletAddress.address + const { id: destinationAssetId } = await createAsset(deps, { + assetOptions: destinationAsset + }) receiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -290,7 +321,8 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toBeUndefined() incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiverWalletAddress.id + walletAddressId: receiverWalletAddress.id, + tenantId: Config.operatorTenantId }) receiver = incomingPayment.getUrl(config.openPaymentsUrl) @@ -316,7 +348,7 @@ describe('OutgoingPaymentService', (): void => { afterEach(async (): Promise => { jest.restoreAllMocks() receiverWalletAddress.scope?.persist(false) - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -327,6 +359,7 @@ describe('OutgoingPaymentService', (): void => { getTests({ createModel: ({ client }) => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -342,6 +375,7 @@ describe('OutgoingPaymentService', (): void => { describe('get', (): void => { test('throws error if cannot find liquidity account for SENDING payment', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -349,6 +383,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id, client @@ -363,6 +398,7 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toEqual(payment) await expect( outgoingPaymentService.fund({ + tenantId, id: payment.id, amount: payment.debitAmount.value, transferId: uuid() @@ -386,6 +422,7 @@ describe('OutgoingPaymentService', (): void => { getPageTests({ createModel: () => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -407,13 +444,18 @@ describe('OutgoingPaymentService', (): void => { let outgoingPayment: OutgoingPayment let otherOutgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - otherSenderWalletAddress = await createWalletAddress(deps, { assetId }) + otherSenderWalletAddress = await createWalletAddress(deps, { + tenantId, + assetId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiverWalletAddress.id + walletAddressId: receiverWalletAddress.id, + tenantId: Config.operatorTenantId }) otherReceiver = incomingPayment.getUrl(config.openPaymentsUrl) outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -427,6 +469,7 @@ describe('OutgoingPaymentService', (): void => { }) otherOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: otherSenderWalletAddress.id, client, receiver: otherReceiver, @@ -474,9 +517,12 @@ describe('OutgoingPaymentService', (): void => { }) test('can filter by state', async (): Promise => { - await OutgoingPayment.query(trx).patchAndFetchById(outgoingPayment.id, { - state: OutgoingPaymentState.Completed - }) + await OutgoingPayment.query(knex).patchAndFetchById( + outgoingPayment.id, + { + state: OutgoingPaymentState.Completed + } + ) const page = await outgoingPaymentService.getPage({ filter: { @@ -494,12 +540,27 @@ describe('OutgoingPaymentService', (): void => { expect.objectContaining({ id: otherOutgoingPayment.id }) ) }) + + test('can filter by tenantId', async (): Promise => { + await expect( + outgoingPaymentService.getPage({ + tenantId: crypto.randomUUID() + }) + ).resolves.toHaveLength(0) + + await expect( + outgoingPaymentService.getPage({ + tenantId: Config.operatorTenantId + }) + ).resolves.toHaveLength(2) + }) }) }) describe('getWalletAddressPage', (): void => { test('throws error if cannot find liquidity account for SENDING payment', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -507,6 +568,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -522,6 +584,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -573,6 +636,7 @@ describe('OutgoingPaymentService', (): void => { * 4. Based on state, check the result */ const outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -589,6 +653,7 @@ describe('OutgoingPaymentService', (): void => { const response = await outgoingPaymentService.cancel({ id: outgoingPayment.id, + tenantId, reason }) @@ -617,7 +682,8 @@ describe('OutgoingPaymentService', (): void => { const walletAddressId = receiverWalletAddress.id const incomingPaymentUrl = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id const debitAmount = { value: BigInt(123), @@ -628,6 +694,7 @@ describe('OutgoingPaymentService', (): void => { const quoteSpy = jest.spyOn(quoteService, 'create') const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -635,6 +702,7 @@ describe('OutgoingPaymentService', (): void => { expect(!isOutgoingPaymentError(payment)).toBeTruthy() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -668,11 +736,13 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id, grant } @@ -714,11 +784,13 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id, grant } @@ -739,7 +811,8 @@ describe('OutgoingPaymentService', (): void => { const walletAddressId = receiverWalletAddress.id const incomingPaymentUrl = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + [] ).id const debitAmount = { value: BigInt(123), @@ -754,6 +827,7 @@ describe('OutgoingPaymentService', (): void => { ) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -762,6 +836,7 @@ describe('OutgoingPaymentService', (): void => { expect(isOutgoingPaymentError(payment)).toBeTruthy() expect(payment).toBe(OutgoingPaymentError.InvalidAmount) expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -803,12 +878,14 @@ describe('OutgoingPaymentService', (): void => { const peerService = await deps.use('peerService') const peer = await createPeer(deps) const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, method: 'ilp' }) const options = { + tenantId, walletAddressId, client, quoteId: quote.id, @@ -866,23 +943,59 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on unknown wallet address', async () => { const { id: quoteId } = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, validDestination: false, method: 'ilp' }) + const unknownWalletAddressId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) await expect( outgoingPaymentService.create({ - walletAddressId: uuid(), + tenantId, + walletAddressId: unknownWalletAddressId, quoteId }) ).resolves.toEqual(OutgoingPaymentError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + unknownWalletAddressId, + tenantId + ) + }) + + it('fails to create on unknown tenant id', async () => { + const { id: quoteId } = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount, + validDestination: false, + method: 'ilp' + }) + + const unknownTenandId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( + outgoingPaymentService.create({ + tenantId: unknownTenandId, + walletAddressId, + quoteId + }) + ).resolves.toEqual(OutgoingPaymentError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + walletAddressId, + unknownTenandId + ) }) it('fails to create on unknown quote', async () => { await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: uuid() }) @@ -891,6 +1004,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on "consumed" quote', async () => { const { quote } = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -899,6 +1013,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -908,6 +1023,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on invalid quote wallet address', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -916,6 +1032,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: receiverWalletAddress.id, quoteId: quote.id }) @@ -924,6 +1041,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on expired quote', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -935,6 +1053,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -948,6 +1067,7 @@ describe('OutgoingPaymentService', (): void => { `fails to create on $state quote receiver`, async ({ state }): Promise => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -960,6 +1080,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -969,19 +1090,23 @@ describe('OutgoingPaymentService', (): void => { test('fails to create on inactive wallet address', async () => { const { id: quoteId } = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, validDestination: false, method: 'ilp' }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) assert.ok(!walletAddressUpdated.isActive) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: walletAddress.id, quoteId }) @@ -998,6 +1123,7 @@ describe('OutgoingPaymentService', (): void => { const quotes = await Promise.all( [0, 1].map(async (_) => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1007,6 +1133,7 @@ describe('OutgoingPaymentService', (): void => { ) const options = quotes.map((quote) => { return { + tenantId, walletAddressId, client, quoteId: quote.id, @@ -1038,7 +1165,7 @@ describe('OutgoingPaymentService', (): void => { }) ) } - const payments = await OutgoingPayment.query(trx) + const payments = await OutgoingPayment.query(knex) expect(payments.length).toEqual(1) expect([quotes[0].id, quotes[1].id]).toContain(payments[0].id) }) @@ -1049,12 +1176,14 @@ describe('OutgoingPaymentService', (): void => { let interval: string beforeEach(async (): Promise => { quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, method: 'ilp' }) options = { + tenantId, walletAddressId, quoteId: quote.id, metadata: { @@ -1084,6 +1213,7 @@ describe('OutgoingPaymentService', (): void => { receiver } const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1184,6 +1314,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(190) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1198,7 +1329,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(firstPayment) if (failed) { await firstPayment - .$query(trx) + .$query(knex) .patch({ state: OutgoingPaymentState.Failed }) jest @@ -1271,6 +1402,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(7) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1285,7 +1417,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(firstPayment) if (failed) { await firstPayment - .$query(trx) + .$query(knex) .patch({ state: OutgoingPaymentState.Failed }) if (half) { jest @@ -1332,13 +1464,23 @@ describe('OutgoingPaymentService', (): void => { id: grant.id }) + const paymentMethods: OpenPaymentsPaymentMethod[] = [ + { + type: 'ilp', + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: '' + } + ] + const options: CreateOutgoingPaymentOptions = { walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, - receiverWalletAddress + receiverWalletAddress, + paymentMethods ).id, + tenantId, grant } @@ -1373,6 +1515,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -1394,11 +1537,13 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.walletAddress) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(config.openPaymentsUrl), receiveAmount, method: 'ilp' @@ -1431,6 +1576,7 @@ describe('OutgoingPaymentService', (): void => { .spyOn(telemetryService!, 'incrementCounter') .mockImplementation(() => Promise.resolve()) const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1475,11 +1621,13 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value * 2n, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.walletAddress) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(config.openPaymentsUrl), receiveAmount, method: 'ilp' @@ -1515,6 +1663,7 @@ describe('OutgoingPaymentService', (): void => { const spyCounter = jest.spyOn(telemetryService, 'incrementCounter') const createdPayment = await setup({ + tenantId, receiver, debitAmount, receiveAmount, @@ -1545,7 +1694,8 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value * 2n, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.id) assert.ok(incomingPayment.createdAt) @@ -1568,6 +1718,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(incomingPayment.receivedAmount?.assetScale) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(config.openPaymentsUrl), receiveAmount, method: 'ilp' @@ -1590,6 +1741,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (with incoming payment initially partially paid)', async (): Promise => { const createdPayment = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1627,6 +1779,7 @@ describe('OutgoingPaymentService', (): void => { test('SENDING -> FAILED (partial payment then retryable Pay error)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1677,6 +1830,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (non-retryable error)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1708,6 +1862,7 @@ describe('OutgoingPaymentService', (): void => { test('SENDING→COMPLETED (partial payment, resume, complete)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1739,6 +1894,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const createdPayment = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1770,6 +1926,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1795,6 +1952,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (source asset changed)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1803,8 +1961,10 @@ describe('OutgoingPaymentService', (): void => { ) const { id: assetId } = await createAsset(deps, { - code: asset.code, - scale: asset.scale + 1 + assetOptions: { + code: asset.code, + scale: asset.scale + 1 + } }) await OutgoingPayment.relatedQuery('walletAddress').for(paymentId).patch({ @@ -1819,6 +1979,7 @@ describe('OutgoingPaymentService', (): void => { }) test('FAILED (destination asset changed)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1842,6 +2003,7 @@ describe('OutgoingPaymentService', (): void => { test('QuoteExpired (current time is greater than the payment quote expiration time)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1866,6 +2028,7 @@ describe('OutgoingPaymentService', (): void => { beforeEach(async (): Promise => { payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1884,6 +2047,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: uuid(), + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1894,6 +2058,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1913,6 +2078,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount - BigInt(1), transferId: uuid() }) @@ -1932,6 +2098,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount, transferId: uuid() }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 3e2aa2cce0..1cc63bdcab 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -104,16 +104,21 @@ interface GetPageOptions { pagination?: Pagination filter?: OutgoingPaymentFilter sortOrder?: SortOrder + tenantId?: string } async function getOutgoingPaymentsPage( deps: ServiceDependencies, options?: GetPageOptions ): Promise { - const { filter, pagination, sortOrder } = options ?? {} + const { filter, pagination, sortOrder, tenantId } = options ?? {} const query = OutgoingPayment.query(deps.knex).withGraphFetched('quote') + if (tenantId) { + query.where('tenantId', tenantId) + } + if (filter?.receiver?.in && filter.receiver.in.length) { query .innerJoin('quotes', 'quotes.id', 'outgoingPayments.id') @@ -156,11 +161,17 @@ async function getOutgoingPayment( options: GetOptions ): Promise { const outgoingPayment = await OutgoingPayment.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .get(options) .withGraphFetched('quote') if (outgoingPayment) { outgoingPayment.walletAddress = await deps.walletAddressService.get( - outgoingPayment.walletAddressId + outgoingPayment.walletAddressId, + outgoingPayment.tenantId ) outgoingPayment.quote.walletAddress = await deps.walletAddressService.get( outgoingPayment.quote.walletAddressId @@ -174,6 +185,7 @@ async function getOutgoingPayment( } export interface BaseOptions { + tenantId: string walletAddressId: string client?: string grant?: Grant @@ -191,6 +203,7 @@ export interface CreateFromIncomingPayment extends BaseOptions { export type CancelOutgoingPaymentOptions = { id: string + tenantId: string reason?: string } @@ -208,10 +221,15 @@ async function cancelOutgoingPayment( deps: ServiceDependencies, options: CancelOutgoingPaymentOptions ): Promise { - const { id } = options + const { id, tenantId } = options return deps.knex.transaction(async (trx) => { - let payment = await OutgoingPayment.query(trx).findById(id).forUpdate() + let payment = await OutgoingPayment.query(trx) + .findOne({ + id, + tenantId + }) + .forUpdate() if (!payment) return OutgoingPaymentError.UnknownPayment if (payment.state !== OutgoingPaymentState.Funding) { @@ -255,7 +273,7 @@ async function createOutgoingPayment( description: 'Time to create an outgoing payment' } ) - const { walletAddressId } = options + const { walletAddressId, tenantId } = options let quoteId: string if (isCreateFromIncomingPayment(options)) { @@ -268,6 +286,7 @@ async function createOutgoingPayment( ) const { debitAmount, incomingPayment } = options const quoteOrError = await deps.quoteService.create({ + tenantId, receiver: incomingPayment, debitAmount, method: 'ilp', @@ -292,8 +311,10 @@ async function createOutgoingPayment( description: 'Time to get wallet address in outgoing payment' } ) - const walletAddress = - await deps.walletAddressService.get(walletAddressId) + const walletAddress = await deps.walletAddressService.get( + walletAddressId, + tenantId + ) stopTimerWA() if (!walletAddress) { throw OutgoingPaymentError.UnknownWalletAddress @@ -332,9 +353,15 @@ async function createOutgoingPayment( description: 'Time to retrieve peer in outgoing payment' } ) - const peer = await deps.peerService.getByDestinationAddress( - receiver.ilpAddress + const ilpPaymentMethod = receiver.paymentMethods.find( + (method) => method.type === 'ilp' ) + const peer = ilpPaymentMethod + ? await deps.peerService.getByDestinationAddress( + ilpPaymentMethod.ilpAddress, + tenantId + ) + : undefined stopTimerPeer() const payment = await OutgoingPayment.transaction(async (trx) => { @@ -364,6 +391,7 @@ async function createOutgoingPayment( const payment = await OutgoingPayment.query(trx).insertAndFetch({ id: quoteId, + tenantId, walletAddressId: walletAddressId, client: options.client, metadata: options.metadata, @@ -651,17 +679,21 @@ async function validateGrantAndAddSpentAmountsToPayment( export interface FundOutgoingPaymentOptions { id: string + tenantId: string amount: bigint transferId: string } async function fundPayment( deps: ServiceDependencies, - { id, amount, transferId }: FundOutgoingPaymentOptions + { id, tenantId, amount, transferId }: FundOutgoingPaymentOptions ): Promise { return await deps.knex.transaction(async (trx) => { const payment = await OutgoingPayment.query(trx) - .findById(id) + .findOne({ + id, + tenantId + }) .forUpdate() .withGraphFetched('quote') if (!payment) return FundingError.UnknownPayment @@ -710,11 +742,17 @@ async function getWalletAddressPage( options: ListOptions ): Promise { const page = await OutgoingPayment.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .list(options) .withGraphFetched('quote') for (const payment of page) { payment.walletAddress = await deps.walletAddressService.get( - payment.walletAddressId + payment.walletAddressId, + payment.tenantId ) payment.quote.walletAddress = await deps.walletAddressService.get( payment.quote.walletAddressId diff --git a/packages/backend/src/open_payments/quote/model.ts b/packages/backend/src/open_payments/quote/model.ts index b3cd586513..63282769b5 100644 --- a/packages/backend/src/open_payments/quote/model.ts +++ b/packages/backend/src/open_payments/quote/model.ts @@ -7,6 +7,7 @@ import { import { Asset } from '../../asset/model' import { Quote as OpenPaymentsQuote } from '@interledger/open-payments' import { Fee } from '../../fee/model' +import { Tenant } from '../../tenants/model' export class Quote extends WalletAddressSubresource { public static readonly tableName = 'quotes' @@ -26,6 +27,8 @@ export class Quote extends WalletAddressSubresource { public debitAmountMinusFees?: bigint + public tenantId!: string + static get relationMappings() { return { ...super.relationMappings, @@ -44,6 +47,14 @@ export class Quote extends WalletAddressSubresource { from: 'quotes.feeId', to: 'fees.id' } + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant, + join: { + from: 'quotes.tenantId', + to: 'tenants.id' + } } } } @@ -56,7 +67,7 @@ export class Quote extends WalletAddressSubresource { public getUrl(resourceServerUrl: string): string { resourceServerUrl = resourceServerUrl.replace(/\/+$/, '') - return `${resourceServerUrl}${Quote.urlPath}/${this.id}` + return `${resourceServerUrl}/${this.tenantId}${Quote.urlPath}/${this.id}` } public get debitAmount(): Amount { @@ -126,7 +137,7 @@ export class Quote extends WalletAddressSubresource { ): OpenPaymentsQuote { return { id: this.getUrl(resourceServerUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, receiveAmount: serializeAmount(this.receiveAmount), debitAmount: serializeAmount(this.debitAmount), receiver: this.receiver, diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index e31e4ba5fd..3542d2c9f4 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -30,6 +30,7 @@ describe('Quote Routes', (): void => { let quoteRoutes: QuoteRoutes let walletAddress: WalletAddress let baseUrl: string + let tenantId: string const receiver = `https://wallet2.example/incoming-payments/${uuid()}` const asset = randomAsset() @@ -40,13 +41,16 @@ describe('Quote Routes', (): void => { } const createWalletAddressQuote = async ({ + tenantId, walletAddressId, client }: { + tenantId: string walletAddressId: string client?: string }): Promise => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount: { @@ -72,18 +76,22 @@ describe('Quote Routes', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId const { id: assetId } = await createAsset(deps, { - code: debitAmount.assetCode, - scale: debitAmount.assetScale + assetOptions: { + code: debitAmount.assetCode, + scale: debitAmount.assetScale + } }) walletAddress = await createWalletAddress(deps, { + tenantId, assetId }) baseUrl = config.openPaymentsUrl }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -95,14 +103,15 @@ describe('Quote Routes', (): void => { getWalletAddress: async () => walletAddress, createModel: async ({ client }) => createWalletAddressQuote({ + tenantId, walletAddressId: walletAddress.id, client }), get: (ctx) => quoteRoutes.get(ctx), getBody: (quote) => { return { - id: `${baseUrl}/quotes/${quote.id}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${quote.tenantId}/quotes/${quote.id}`, + walletAddress: walletAddress.address, receiver: quote.receiver, debitAmount: serializeAmount(quote.debitAmount), receiveAmount: serializeAmount(quote.receiveAmount), @@ -129,13 +138,16 @@ describe('Quote Routes', (): void => { method: 'POST', url: `/quotes` }, + params: { + tenantId + }, walletAddress, client }) test('returns error on invalid debitAmount asset', async (): Promise => { options = { - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, receiver, debitAmount: { ...debitAmount, @@ -164,7 +176,7 @@ describe('Quote Routes', (): void => { '$description', async ({ debitAmount, receiveAmount }): Promise => { options = { - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, receiver, method: 'ilp' } @@ -194,6 +206,7 @@ describe('Quote Routes', (): void => { }) await expect(quoteRoutes.create(ctx)).resolves.toBeUndefined() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId: walletAddress.id, receiver, debitAmount: options.debitAmount && { @@ -215,8 +228,8 @@ describe('Quote Routes', (): void => { .pop() assert.ok(quote) expect(ctx.response.body).toEqual({ - id: `${baseUrl}/quotes/${quoteId}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${tenantId}/quotes/${quoteId}`, + walletAddress: walletAddress.address, receiver: quote.receiver, debitAmount: { ...quote.debitAmount, @@ -235,7 +248,7 @@ describe('Quote Routes', (): void => { test('receiver.incomingAmount', async (): Promise => { options = { - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, receiver, method: 'ilp' } @@ -253,6 +266,7 @@ describe('Quote Routes', (): void => { }) await expect(quoteRoutes.create(ctx)).resolves.toBeUndefined() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId: walletAddress.id, receiver, client, @@ -266,8 +280,8 @@ describe('Quote Routes', (): void => { .pop() assert.ok(quote) expect(ctx.response.body).toEqual({ - id: `${baseUrl}/quotes/${quoteId}`, - walletAddress: walletAddress.url, + id: `${baseUrl}/${tenantId}/quotes/${quoteId}`, + walletAddress: walletAddress.address, receiver: options.receiver, debitAmount: { ...quote.debitAmount, diff --git a/packages/backend/src/open_payments/quote/routes.ts b/packages/backend/src/open_payments/quote/routes.ts index 85148c0c88..c90150e623 100644 --- a/packages/backend/src/open_payments/quote/routes.ts +++ b/packages/backend/src/open_payments/quote/routes.ts @@ -38,7 +38,8 @@ async function getQuote( ): Promise { const quote = await deps.quoteService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!quote) { @@ -73,7 +74,9 @@ async function createQuote( ctx: CreateContext ): Promise { const { body } = ctx.request + const { tenantId } = ctx.params const options: CreateQuoteOptions = { + tenantId, walletAddressId: ctx.walletAddress.id, receiver: body.receiver, client: ctx.client, diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index f7b5d3bbd6..950ae2a97c 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -35,12 +35,14 @@ import { PaymentMethodHandlerErrorCode } from '../../payment-method/handler/errors' import { Receiver } from '../receiver/model' +import { WalletAddressService } from '../wallet_address/service' describe('QuoteService', (): void => { let deps: IocContract let appContainer: TestContainer let quoteService: QuoteService let paymentMethodHandlerService: PaymentMethodHandlerService + let walletAddressService: WalletAddressService let receiverService: ReceiverService let knex: Knex let sendingWalletAddress: MockWalletAddress @@ -53,6 +55,7 @@ describe('QuoteService', (): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any any > + let tenantId: string const asset: AssetOptions = { scale: 9, @@ -87,19 +90,27 @@ describe('QuoteService', (): void => { config = await deps.use('config') quoteService = await deps.use('quoteService') paymentMethodHandlerService = await deps.use('paymentMethodHandlerService') + walletAddressService = await deps.use('walletAddressService') receiverService = await deps.use('receiverService') }) beforeEach(async (): Promise => { + tenantId = config.operatorTenantId const { id: sendAssetId } = await createAsset(deps, { - code: debitAmount.assetCode, - scale: debitAmount.assetScale + assetOptions: { + code: debitAmount.assetCode, + scale: debitAmount.assetScale + } }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: sendAssetId }) - const { id: destinationAssetId } = await createAsset(deps, destinationAsset) + const { id: destinationAssetId } = await createAsset(deps, { + assetOptions: destinationAsset + }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -125,7 +136,7 @@ describe('QuoteService', (): void => { jest.restoreAllMocks() jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -136,8 +147,9 @@ describe('QuoteService', (): void => { getTests({ createModel: ({ client }) => createQuote(deps, { + tenantId, walletAddressId: sendingWalletAddress.id, - receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, + receiver: `${receivingWalletAddress.address}/incoming-payments/${uuid()}`, debitAmount: { value: BigInt(56), assetCode: asset.code, @@ -177,9 +189,11 @@ describe('QuoteService', (): void => { beforeEach(async (): Promise => { incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) options = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(config.openPaymentsUrl), method: 'ilp' @@ -256,6 +270,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -346,6 +361,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -386,9 +402,11 @@ describe('QuoteService', (): void => { const incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, incomingAmount, - expiresAt: expiryDate + expiresAt: expiryDate, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(config.openPaymentsUrl), receiveAmount, @@ -429,28 +447,62 @@ describe('QuoteService', (): void => { }) } ) + test('fails on unknown tenant id', async (): Promise => { + const walletAddress = await createWalletAddress(deps, { + tenantId + }) + const unknownTenantId = uuid() + + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( + quoteService.create({ + tenantId: unknownTenantId, + walletAddressId: walletAddress.id, + receiver: `${receivingWalletAddress.address}/incoming-payments/${uuid()}`, + debitAmount, + method: 'ilp' + }) + ).resolves.toMatchObject({ type: QuoteErrorCode.UnknownWalletAddress }) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + walletAddress.id, + unknownTenantId + ) + }) test('fails on unknown wallet address', async (): Promise => { + const unknownWalletAddressId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( quoteService.create({ - walletAddressId: uuid(), - receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, + tenantId, + walletAddressId: unknownWalletAddressId, + receiver: `${receivingWalletAddress.address}/incoming-payments/${uuid()}`, debitAmount, method: 'ilp' }) ).resolves.toMatchObject({ type: QuoteErrorCode.UnknownWalletAddress }) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + unknownWalletAddressId, + tenantId + ) }) test('fails on inactive wallet address', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) assert.ok(!walletAddressUpdated.isActive) await expect( quoteService.create({ + tenantId, walletAddressId: walletAddress.id, - receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, + receiver: `${receivingWalletAddress.address}/incoming-payments/${uuid()}`, debitAmount, method: 'ilp' }) @@ -460,8 +512,9 @@ describe('QuoteService', (): void => { test('fails on invalid receiver', async (): Promise => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, - receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, + receiver: `${receivingWalletAddress.address}/incoming-payments/${uuid()}`, debitAmount, method: 'ilp' }) @@ -482,6 +535,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp', @@ -509,9 +563,11 @@ describe('QuoteService', (): void => { 'fails to create $description', async ({ debitAmount, receiveAmount }): Promise => { const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receivingWalletAddress.id + walletAddressId: receivingWalletAddress.id, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(config.openPaymentsUrl), method: 'ilp' @@ -531,13 +587,17 @@ describe('QuoteService', (): void => { beforeEach(async (): Promise => { asset = await createAsset(deps, { - code: 'USD', - scale: 2 + assetOptions: { + code: 'USD', + scale: 2 + } }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) }) @@ -561,7 +621,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) await Fee.query().insertAndFetch({ @@ -582,6 +643,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -603,7 +665,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) const mockedQuote = mockQuote({ @@ -619,6 +682,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -635,17 +699,23 @@ describe('QuoteService', (): void => { beforeEach(async (): Promise => { sendAsset = await createAsset(deps, { - code: 'USD', - scale: 2 + assetOptions: { + code: 'USD', + scale: 2 + } }) receiveAsset = await createAsset(deps, { - code: 'XRP', - scale: 2 + assetOptions: { + code: 'XRP', + scale: 2 + } }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: sendAsset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: receiveAsset.id }) }) @@ -690,6 +760,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -756,6 +827,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -799,6 +871,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -822,10 +895,12 @@ describe('QuoteService', (): void => { test('Local receiver uses local payment method', async () => { const incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(config.openPaymentsUrl), method: 'ilp' @@ -874,6 +949,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 623ba76f7f..c95bf9ce65 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -59,19 +59,27 @@ async function getQuote( deps: ServiceDependencies, options: GetOptions ): Promise { - const quote = await Quote.query(deps.knex).get(options) + const quote = await Quote.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) + .get(options) if (quote) { const asset = await deps.assetService.get(quote.assetId) if (asset) quote.asset = asset quote.walletAddress = await deps.walletAddressService.get( - quote.walletAddressId + quote.walletAddressId, + quote.tenantId ) } return quote } interface QuoteOptionsBase { + tenantId: string walletAddressId: string receiver: string method: 'ilp' @@ -94,6 +102,7 @@ export type CreateQuoteOptions = interface UnfinalizedQuote { id: string + tenantId: string walletAddressId: string assetId: string receiver: string @@ -118,7 +127,8 @@ async function createQuote( return new QuoteError(QuoteErrorCode.InvalidAmount) } const walletAddress = await deps.walletAddressService.get( - options.walletAddressId + options.walletAddressId, + options.tenantId ) if (!walletAddress) { stopTimer() @@ -247,6 +257,7 @@ async function createQuote( const unfinalizedQuote: UnfinalizedQuote = { id: quoteId, + tenantId: options.tenantId, walletAddressId: options.walletAddressId, assetId: walletAddress.assetId, receiver: options.receiver, @@ -498,13 +509,20 @@ async function getWalletAddressPage( deps: ServiceDependencies, options: ListOptions ): Promise { - const quotes = await Quote.query(deps.knex).list(options) + const quotes = await Quote.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) + .list(options) for (const quote of quotes) { const asset = await deps.assetService.get(quote.assetId) if (asset) quote.asset = asset quote.walletAddress = await deps.walletAddressService.get( - quote.walletAddressId + quote.walletAddressId, + quote.tenantId ) } return quotes diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index 722082cad4..c256eb0eac 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -6,32 +6,23 @@ import { AppServices } from '../../app' import { createIncomingPayment } from '../../tests/incomingPayment' import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' -import { - IlpStreamCredentials, - StreamCredentialsService -} from '../../payment-method/ilp/stream-credentials/service' import { Receiver } from './model' import { IncomingPaymentState } from '../payment/incoming/model' -import assert from 'assert' -import base64url from 'base64url' -import { IlpAddress } from 'ilp-packet' describe('Receiver Model', (): void => { let deps: IocContract let appContainer: TestContainer - let streamCredentialsService: StreamCredentialsService let config: IAppConfig beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) - streamCredentialsService = await deps.use('streamCredentialsService') config = await deps.use('config') }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -40,20 +31,20 @@ describe('Receiver Model', (): void => { describe('constructor', () => { test('creates receiver', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) const isLocal = true - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) - const receiver = new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ), isLocal ) @@ -61,45 +52,36 @@ describe('Receiver Model', (): void => { expect(receiver).toEqual({ assetCode: incomingPayment.asset.code, assetScale: incomingPayment.asset.scale, - ilpAddress: expect.any(String), - sharedSecret: expect.any(Buffer), incomingPayment: { id: incomingPayment.getUrl(config.openPaymentsUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, createdAt: incomingPayment.createdAt, completed: incomingPayment.completed, receivedAmount: incomingPayment.receivedAmount, incomingAmount: incomingPayment.incomingAmount, expiresAt: incomingPayment.expiresAt, - methods: [ - { - type: 'ilp', - ilpAddress: streamCredentials.ilpAddress, - sharedSecret: base64url(streamCredentials.sharedSecret) - } - ] + methods: [] }, isLocal }) }) test('doesnt throw if incoming payment is completed', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.state = IncomingPaymentState.Completed - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) expect( @@ -108,67 +90,44 @@ describe('Receiver Model', (): void => { }) test('doesnt throw if incoming payment is expired', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.expiresAt = new Date(Date.now() - 1) - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) + const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) expect( () => new Receiver(openPaymentsIncomingPayment, false) ).not.toThrow() }) - - test('throws if stream credentials has invalid ILP address', async () => { - const walletAddress = await createWalletAddress(deps) - const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id - }) - - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) - ;(streamCredentials.ilpAddress as string) = 'not base 64 encoded' - - const openPaymentsIncomingPayment = - incomingPayment.toOpenPaymentsTypeWithMethods( - config.openPaymentsUrl, - walletAddress, - streamCredentials - ) - - expect(() => new Receiver(openPaymentsIncomingPayment, false)).toThrow( - 'Invalid ILP address on ilp payment method' - ) - }) }) describe('isActive', () => { test('returns false if incoming payment is completed', async () => { const walletAddress = await createWalletAddress(deps) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId }) incomingPayment.state = IncomingPaymentState.Completed - const streamCredentials: IlpStreamCredentials = { - ilpAddress: 'test.ilp' as IlpAddress, - sharedSecret: Buffer.from('') - } const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) const receiver = new Receiver(openPaymentsIncomingPayment, false) @@ -179,17 +138,16 @@ describe('Receiver Model', (): void => { test('returns false if incoming payment is expired', async () => { const walletAddress = await createWalletAddress(deps) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId }) incomingPayment.expiresAt = new Date(Date.now() - 1) - const streamCredentials = streamCredentialsService.get(incomingPayment) - assert(streamCredentials) const openPaymentsIncomingPayment = incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + [] ) const receiver = new Receiver(openPaymentsIncomingPayment, false) diff --git a/packages/backend/src/open_payments/receiver/model.ts b/packages/backend/src/open_payments/receiver/model.ts index 6a7034b782..6c59b2a71e 100644 --- a/packages/backend/src/open_payments/receiver/model.ts +++ b/packages/backend/src/open_payments/receiver/model.ts @@ -1,10 +1,7 @@ -import { Counter, ResolvedPayment } from '@interledger/pay' -import base64url from 'base64url' - import { Amount, parseAmount } from '../amount' import { AssetOptions } from '../../asset/service' import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethod } from '@interledger/open-payments' -import { IlpAddress, isValidIlpAddress } from 'ilp-packet' +import { OpenPaymentsPaymentMethod } from '../../payment-method/provider/service' type ReceiverIncomingPayment = Readonly< Omit< @@ -19,8 +16,6 @@ type ReceiverIncomingPayment = Readonly< > export class Receiver { - public readonly ilpAddress: IlpAddress - public readonly sharedSecret: Buffer public readonly assetCode: string public readonly assetScale: number public readonly incomingPayment: ReceiverIncomingPayment @@ -39,19 +34,6 @@ export class Receiver { : undefined const receivedAmount = parseAmount(incomingPayment.receivedAmount) - // TODO: handle multiple payment methods - const ilpMethod = incomingPayment.methods?.find( - (method) => method.type === 'ilp' - ) - if (!ilpMethod) { - throw new Error('Cannot create receiver from unsupported payment method') - } - if (!isValidIlpAddress(ilpMethod.ilpAddress)) { - throw new Error('Invalid ILP address on ilp payment method') - } - - this.ilpAddress = ilpMethod.ilpAddress - this.sharedSecret = base64url.toBuffer(ilpMethod.sharedSecret) this.assetCode = incomingPayment.receivedAmount.assetCode this.assetScale = incomingPayment.receivedAmount.assetScale @@ -94,13 +76,8 @@ export class Receiver { return undefined } - public toResolvedPayment(): ResolvedPayment { - return { - destinationAsset: this.asset, - destinationAddress: this.ilpAddress, - sharedSecret: this.sharedSecret, - requestCounter: Counter.from(0) as Counter - } + public get paymentMethods(): OpenPaymentsPaymentMethod[] { + return this.incomingPayment.methods } public isActive(): boolean { diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 607be789d6..765b06ef06 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -32,7 +32,7 @@ import { RemoteIncomingPaymentError } from '../payment/incoming_remote/errors' import assert from 'assert' import { Receiver } from './model' import { IncomingPayment } from '../payment/incoming/model' -import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' +import { PaymentMethodProviderService } from '../../payment-method/provider/service' describe('Receiver Service', (): void => { let deps: IocContract @@ -42,9 +42,10 @@ describe('Receiver Service', (): void => { let incomingPaymentService: IncomingPaymentService let knex: Knex let walletAddressService: WalletAddressService - let streamCredentialsService: StreamCredentialsService + let paymentMethodProviderService: PaymentMethodProviderService let remoteIncomingPaymentService: RemoteIncomingPaymentService let serviceDeps: ServiceDependencies + let tenantId: string beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -53,7 +54,9 @@ describe('Receiver Service', (): void => { receiverService = await deps.use('receiverService') incomingPaymentService = await deps.use('incomingPaymentService') walletAddressService = await deps.use('walletAddressService') - streamCredentialsService = await deps.use('streamCredentialsService') + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) remoteIncomingPaymentService = await deps.use( 'remoteIncomingPaymentService' ) @@ -65,14 +68,15 @@ describe('Receiver Service', (): void => { incomingPaymentService, remoteIncomingPaymentService, walletAddressService, - streamCredentialsService, + paymentMethodProviderService, telemetry: await deps.use('telemetry') } + tenantId = Config.operatorTenantId }) afterEach(async (): Promise => { jest.restoreAllMocks() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -83,6 +87,7 @@ describe('Receiver Service', (): void => { describe('local incoming payment', () => { test('resolves local incoming payment', async () => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort }) const incomingPayment = await createIncomingPayment(deps, { @@ -91,32 +96,29 @@ describe('Receiver Service', (): void => { value: BigInt(5), assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) + await expect( receiverService.get(incomingPayment.getUrl(config.openPaymentsUrl)) ).resolves.toEqual({ assetCode: incomingPayment.receivedAmount.assetCode, assetScale: incomingPayment.receivedAmount.assetScale, - ilpAddress: expect.any(String), - sharedSecret: expect.any(Buffer), incomingPayment: { id: incomingPayment.getUrl(config.openPaymentsUrl), - walletAddress: walletAddress.url, + walletAddress: walletAddress.address, incomingAmount: incomingPayment.incomingAmount, receivedAmount: incomingPayment.receivedAmount, completed: false, metadata: undefined, expiresAt: incomingPayment.expiresAt, createdAt: incomingPayment.createdAt, - methods: [ - { - type: 'ilp', - ilpAddress: expect.any(String), - sharedSecret: expect.any(String) - } - ] + methods: [] }, isLocal: true }) @@ -165,10 +167,11 @@ describe('Receiver Service', (): void => { ) }) - test('returns object without methods if stream credentials could not be generated', async () => { + test('returns object with empty payment methods if payment methods could not be generated', async () => { const walletAddress = await createWalletAddress(deps) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: walletAddress.tenantId }) jest @@ -176,8 +179,8 @@ describe('Receiver Service', (): void => { .mockResolvedValueOnce(incomingPayment) jest - .spyOn(streamCredentialsService, 'get') - .mockReturnValueOnce(undefined) + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect( getLocalIncomingPayment( @@ -208,8 +211,6 @@ describe('Receiver Service', (): void => { ).resolves.toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, @@ -271,8 +272,6 @@ describe('Receiver Service', (): void => { ).resolves.toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, @@ -314,11 +313,14 @@ describe('Receiver Service', (): void => { beforeEach(async () => { const asset = await createAsset(deps, { - code: 'USD', - scale: 2 + assetOptions: { + code: 'USD', + scale: 2 + } }) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort, assetId: asset.id }) @@ -339,19 +341,29 @@ describe('Receiver Service', (): void => { remoteIncomingPaymentService, 'create' ) + + jest + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: 'secret' + } + ]) + const receiver = await receiverService.create({ - walletAddressUrl: walletAddress.url, + walletAddressUrl: walletAddress.address, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) assert(receiver instanceof Receiver) expect(receiver).toEqual({ assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - ilpAddress: receiver.ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: receiver.incomingPayment?.id, walletAddress: receiver.incomingPayment?.walletAddress, @@ -361,13 +373,7 @@ describe('Receiver Service', (): void => { metadata: receiver.incomingPayment?.metadata || undefined, createdAt: receiver.incomingPayment?.createdAt, expiresAt: receiver.incomingPayment?.expiresAt, - methods: [ - { - type: 'ilp', - ilpAddress: receiver.ilpAddress, - sharedSecret: expect.any(String) - } - ] + methods: receiver.paymentMethods }, isLocal: true }) @@ -376,7 +382,8 @@ describe('Receiver Service', (): void => { walletAddressId: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId: Config.operatorTenantId }) expect(remoteIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -389,22 +396,24 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.url + walletAddressUrl: walletAddress.address, + tenantId }) ).resolves.toEqual(ReceiverError.InvalidAmount) }) - test('throws error if stream credentials could not be generated', async () => { + test('throws error if could not generate any payment methods', async () => { jest - .spyOn(streamCredentialsService, 'get') - .mockReturnValueOnce(undefined) + .spyOn(paymentMethodProviderService, 'getPaymentMethods') + .mockResolvedValueOnce([]) await expect( receiverService.create({ - walletAddressUrl: walletAddress.url + walletAddressUrl: walletAddress.address, + tenantId }) ).rejects.toThrow( - 'Could not get stream credentials for local incoming payment' + 'Could not get any payment methods during local incoming payment creation' ) }) }) @@ -444,19 +453,17 @@ describe('Receiver Service', (): void => { incomingPaymentService, 'create' ) - const receiver = await receiverService.create({ walletAddressUrl: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) expect(receiver).toEqual({ assetCode: mockedIncomingPayment.receivedAmount.assetCode, assetScale: mockedIncomingPayment.receivedAmount.assetScale, - ilpAddress: mockedIncomingPayment.methods[0].ilpAddress, - sharedSecret: expect.any(Buffer), incomingPayment: { id: mockedIncomingPayment.id, walletAddress: mockedIncomingPayment.walletAddress, @@ -484,7 +491,8 @@ describe('Receiver Service', (): void => { walletAddressUrl: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -499,7 +507,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.id + walletAddressUrl: walletAddress.id, + tenantId }) ).resolves.toEqual(ReceiverError.UnknownWalletAddress) }) @@ -519,7 +528,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: mockedIncomingPayment.walletAddress + walletAddressUrl: mockedIncomingPayment.walletAddress, + tenantId }) ).rejects.toThrow('Could not create receiver from incoming payment') expect(remoteIncomingPaymentServiceCreateSpy).toHaveBeenCalledTimes(1) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index f63003c8eb..d9858fdbbd 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,5 +1,4 @@ import { IncomingPaymentWithPaymentMethods as OpenPaymentsIncomingPaymentWithPaymentMethods } from '@interledger/open-payments' -import { StreamCredentialsService } from '../../payment-method/ilp/stream-credentials/service' import { WalletAddressService } from '../wallet_address/service' import { BaseService } from '../../shared/baseService' import { IncomingPaymentService } from '../payment/incoming/service' @@ -16,12 +15,14 @@ import { import { isRemoteIncomingPaymentError } from '../payment/incoming_remote/errors' import { TelemetryService } from '../../telemetry/service' import { IAppConfig } from '../../config/app' +import { PaymentMethodProviderService } from '../../payment-method/provider/service' interface CreateReceiverArgs { walletAddressUrl: string expiresAt?: Date incomingAmount?: Amount metadata?: Record + tenantId: string } // A receiver is resolved from an incoming payment @@ -32,11 +33,11 @@ export interface ReceiverService { export interface ServiceDependencies extends BaseService { config: IAppConfig - streamCredentialsService: StreamCredentialsService incomingPaymentService: IncomingPaymentService walletAddressService: WalletAddressService remoteIncomingPaymentService: RemoteIncomingPaymentService telemetry: TelemetryService + paymentMethodProviderService: PaymentMethodProviderService } const INCOMING_PAYMENT_URL_REGEX = @@ -105,13 +106,14 @@ async function createLocalIncomingPayment( args: CreateReceiverArgs, walletAddress: WalletAddress ): Promise { - const { expiresAt, incomingAmount, metadata } = args + const { expiresAt, incomingAmount, metadata, tenantId } = args const incomingPaymentOrError = await deps.incomingPaymentService.create({ walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -124,14 +126,18 @@ async function createLocalIncomingPayment( return incomingPaymentOrError } - const streamCredentials = deps.streamCredentialsService.get( - incomingPaymentOrError - ) + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods( + incomingPaymentOrError + ) - if (!streamCredentials) { + if (paymentMethods.length === 0) { const errorMessage = - 'Could not get stream credentials for local incoming payment' - deps.logger.error({ err: incomingPaymentOrError }, errorMessage) + 'Could not get any payment methods during local incoming payment creation' + deps.logger.error( + { incomingPaymentId: incomingPaymentOrError.id }, + errorMessage + ) throw new Error(errorMessage) } @@ -139,7 +145,7 @@ async function createLocalIncomingPayment( return incomingPaymentOrError.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, walletAddress, - streamCredentials + paymentMethods ) } @@ -211,12 +217,13 @@ export async function getLocalIncomingPayment( throw new Error(errorMessage) } - const streamCredentials = deps.streamCredentialsService.get(incomingPayment) + const paymentMethods = + await deps.paymentMethodProviderService.getPaymentMethods(incomingPayment) return incomingPayment.toOpenPaymentsTypeWithMethods( deps.config.openPaymentsUrl, incomingPayment.walletAddress, - streamCredentials + paymentMethods ) } diff --git a/packages/backend/src/open_payments/wallet_address/errors.ts b/packages/backend/src/open_payments/wallet_address/errors.ts index 03d672762c..6d0f2151cd 100644 --- a/packages/backend/src/open_payments/wallet_address/errors.ts +++ b/packages/backend/src/open_payments/wallet_address/errors.ts @@ -4,7 +4,8 @@ export enum WalletAddressError { InvalidUrl = 'InvalidUrl', UnknownAsset = 'UnknownAsset', UnknownWalletAddress = 'UnknownWalletAddress', - DuplicateWalletAddress = 'DuplicateWalletAddress' + DuplicateWalletAddress = 'DuplicateWalletAddress', + WalletAddressSettingNotFound = 'WalletAddressSettingNotFound' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -17,7 +18,8 @@ export const errorToCode: { [WalletAddressError.InvalidUrl]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownAsset]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownWalletAddress]: GraphQLErrorCode.NotFound, - [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate + [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate, + [WalletAddressError.WalletAddressSettingNotFound]: GraphQLErrorCode.NotFound } export const errorToMessage: { @@ -27,5 +29,7 @@ export const errorToMessage: { [WalletAddressError.UnknownAsset]: 'unknown asset', [WalletAddressError.UnknownWalletAddress]: 'unknown wallet address', [WalletAddressError.DuplicateWalletAddress]: - 'Duplicate wallet address found with the same url' + 'Duplicate wallet address found with the same url', + [WalletAddressError.WalletAddressSettingNotFound]: + 'Setting for wallet address has not been found.' } diff --git a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts index c97153ed42..73222c87cb 100644 --- a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts @@ -37,7 +37,7 @@ describe('Wallet Address Keys Routes', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -46,7 +46,9 @@ describe('Wallet Address Keys Routes', (): void => { describe('get', (): void => { test('returns 200 with all keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const keyOption = { walletAddressId: walletAddress.id, @@ -59,7 +61,7 @@ describe('Wallet Address Keys Routes', (): void => { headers: { Accept: 'application/json' }, url: `/jwks.json` }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address await expect(walletAddressKeyRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() @@ -69,13 +71,15 @@ describe('Wallet Address Keys Routes', (): void => { }) test('returns 200 with empty array if no keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const ctx = createContext({ headers: { Accept: 'application/json' }, url: `/jwks.json` }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address await expect(walletAddressKeyRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.body).toEqual({ @@ -121,14 +125,16 @@ describe('Wallet Address Keys Routes', (): void => { }) test('throws 404 error for inactive wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await walletAddress.$query().patch({ deactivatedAt: new Date() }) const ctx = createContext({ headers: { Accept: 'application/json' } }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address const getOrPollByUrlSpy = jest.spyOn( walletAddressService, diff --git a/packages/backend/src/open_payments/wallet_address/key/service.test.ts b/packages/backend/src/open_payments/wallet_address/key/service.test.ts index 8fa5802486..c9a0970aa6 100644 --- a/packages/backend/src/open_payments/wallet_address/key/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/service.test.ts @@ -31,11 +31,13 @@ describe('Wallet Address Key Service', (): void => { }) beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/open_payments/wallet_address/middleware.test.ts b/packages/backend/src/open_payments/wallet_address/middleware.test.ts index 6b1b5572dc..13471c335c 100644 --- a/packages/backend/src/open_payments/wallet_address/middleware.test.ts +++ b/packages/backend/src/open_payments/wallet_address/middleware.test.ts @@ -32,6 +32,9 @@ import { OutgoingPaymentService } from '../payment/outgoing/service' import { Quote } from '../quote/model' import { IncomingPayment } from '../payment/incoming/model' import { OutgoingPayment } from '../payment/outgoing/model' +import { createOutgoingPayment } from '../../tests/outgoingPayment' +import { createAsset } from '../../tests/asset' +import { AssetOptions } from '../../asset/service' describe('Wallet Address Middleware', (): void => { let deps: IocContract @@ -51,7 +54,8 @@ describe('Wallet Address Middleware', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + jest.restoreAllMocks() + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -140,7 +144,7 @@ describe('Wallet Address Middleware', (): void => { jest.spyOn(incomingPaymentService, 'get').mockResolvedValueOnce({ id: incomingPaymentId, walletAddress: { - url: walletAddressUrl + address: walletAddressUrl } } as IncomingPayment) @@ -201,7 +205,7 @@ describe('Wallet Address Middleware', (): void => { jest.spyOn(quoteService, 'get').mockResolvedValueOnce({ id: quoteId, walletAddress: { - url: walletAddressUrl + address: walletAddressUrl } } as Quote) @@ -227,6 +231,60 @@ describe('Wallet Address Middleware', (): void => { expect(next).toHaveBeenCalled() }) + test('throws error if could not find existing quote for mismatched tenantId', async () => { + const tenantId = Config.operatorTenantId + + const asset: AssetOptions = { + scale: 9, + code: 'USD' + } + const { id: sendAssetId } = await createAsset(deps, { + assetOptions: asset + }) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: sendAssetId + }) + + const existingQuoteId = ( + await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, + walletAddressId: walletAddress.id, + method: 'ilp', + receiver: `${ + Config.openPaymentsUrl + }/${crypto.randomUUID()}/incoming-payments/${crypto.randomUUID()}`, + debitAmount: { + value: BigInt(456), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + validDestination: false + }) + ).quote.id + + const ctx: WalletAddressUrlContext = createContext( + { headers: { Accept: 'application/json' } }, + { + id: existingQuoteId, + tenantId: crypto.randomUUID() + } + ) + + ctx.container = deps + const next = jest.fn() + + expect.assertions(3) + try { + await getWalletAddressUrlFromQuote(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toBe('Unauthorized') + expect(next).not.toHaveBeenCalled() + } + }) + test('throws error if could not find quote', async (): Promise => { const ctx: WalletAddressUrlContext = createContext( { @@ -257,7 +315,7 @@ describe('Wallet Address Middleware', (): void => { }) }) - describe('getWalletAddressUrlFromOutgoingPayment', () => { + describe('getWalletAddressUrlFromOutgoingPayment', (): void => { test('sets walletAddressUrl', async (): Promise => { const walletAddressUrl = 'https://example.com/test' const outgoingPaymentId = crypto.randomUUID() @@ -265,7 +323,7 @@ describe('Wallet Address Middleware', (): void => { jest.spyOn(outgoingPaymentService, 'get').mockResolvedValueOnce({ id: outgoingPaymentId, walletAddress: { - url: walletAddressUrl + address: walletAddressUrl } } as OutgoingPayment) @@ -291,6 +349,60 @@ describe('Wallet Address Middleware', (): void => { expect(next).toHaveBeenCalled() }) + test('throws error if could not find existing outgoing payment for mismatched tenantId', async (): Promise => { + const tenantId = Config.operatorTenantId + + const asset: AssetOptions = { + scale: 9, + code: 'USD' + } + const { id: sendAssetId } = await createAsset(deps, { + assetOptions: asset + }) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: sendAssetId + }) + + const existingPaymentId = ( + await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, + walletAddressId: walletAddress.id, + method: 'ilp', + receiver: `${ + Config.openPaymentsUrl + }/${crypto.randomUUID()}/incoming-payments/${crypto.randomUUID()}`, + debitAmount: { + value: BigInt(456), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + validDestination: false + }) + ).id + + const ctx: WalletAddressUrlContext = createContext( + { headers: { Accept: 'application/json' } }, + { + id: existingPaymentId, + tenantId: crypto.randomUUID() + } + ) + + ctx.container = deps + const next = jest.fn() + + expect.assertions(3) + try { + await getWalletAddressUrlFromOutgoingPayment(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toBe('Unauthorized') + expect(next).not.toHaveBeenCalled() + } + }) + test('throws error if could not find outgoing payment', async (): Promise => { const ctx: WalletAddressUrlContext = createContext( { @@ -356,8 +468,10 @@ describe('Wallet Address Middleware', (): void => { }) test('throws error for deactivated wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) - ctx.walletAddressUrl = walletAddress.url + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) + ctx.walletAddressUrl = walletAddress.address await walletAddress.$query().patch({ deactivatedAt: new Date() }) @@ -373,8 +487,10 @@ describe('Wallet Address Middleware', (): void => { }) test('sets walletAddress on context and calls next', async (): Promise => { - const walletAddress = await createWalletAddress(deps) - ctx.walletAddressUrl = walletAddress.url + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) + ctx.walletAddressUrl = walletAddress.address await expect( getWalletAddressForSubresource(ctx, next) diff --git a/packages/backend/src/open_payments/wallet_address/middleware.ts b/packages/backend/src/open_payments/wallet_address/middleware.ts index 86ffca590c..cd6f053568 100644 --- a/packages/backend/src/open_payments/wallet_address/middleware.ts +++ b/packages/backend/src/open_payments/wallet_address/middleware.ts @@ -43,7 +43,8 @@ export async function getWalletAddressUrlFromIncomingPayment( 'incomingPaymentService' ) const incomingPayment = await incomingPaymentService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!incomingPayment?.walletAddress) { @@ -53,7 +54,7 @@ export async function getWalletAddressUrlFromIncomingPayment( }) } - ctx.walletAddressUrl = incomingPayment.walletAddress.url + ctx.walletAddressUrl = incomingPayment.walletAddress.address await next() } @@ -65,7 +66,8 @@ export async function getWalletAddressUrlFromOutgoingPayment( 'outgoingPaymentService' ) const outgoingPayment = await outgoingPaymentService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!outgoingPayment?.walletAddress) { @@ -75,7 +77,7 @@ export async function getWalletAddressUrlFromOutgoingPayment( }) } - ctx.walletAddressUrl = outgoingPayment.walletAddress.url + ctx.walletAddressUrl = outgoingPayment.walletAddress.address await next() } @@ -85,7 +87,8 @@ export async function getWalletAddressUrlFromQuote( ) { const quoteService = await ctx.container.use('quoteService') const quote = await quoteService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!quote?.walletAddress) { @@ -95,7 +98,7 @@ export async function getWalletAddressUrlFromQuote( }) } - ctx.walletAddressUrl = quote.walletAddress.url + ctx.walletAddressUrl = quote.walletAddress.address await next() } diff --git a/packages/backend/src/open_payments/wallet_address/model.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index 6aaeb79184..cc7bd279b4 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -58,7 +58,7 @@ export const setup = < options.params ) ctx.walletAddress = options.walletAddress - ctx.walletAddressUrl = options.walletAddress.url + ctx.walletAddressUrl = options.walletAddress.address ctx.grant = options.grant ctx.client = options.client ctx.accessAction = options.accessAction @@ -377,7 +377,7 @@ describe('Models', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -413,7 +413,9 @@ describe('Models', (): void => { test.each(deactivatedAtCases)( '$description', async ({ value, expectedIsActive }) => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (value) { await walletAddress .$query(appContainer.knex) diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index 81dd603a1d..4c06ef1a55 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -4,10 +4,11 @@ import { LiquidityAccount, OnCreditOptions } from '../../accounting/service' import { ConnectorAccount } from '../../payment-method/ilp/connector/core/rafiki' import { Asset } from '../../asset/model' import { BaseModel, Pagination, SortOrder } from '../../shared/baseModel' -import { WebhookEvent } from '../../webhook/model' +import { WebhookEvent } from '../../webhook/event/model' import { WalletAddressKey } from '../../open_payments/wallet_address/key/model' import { AmountJSON } from '../amount' import { WalletAddressAdditionalProperty } from './additional_property/model' +import { Tenant } from '../../tenants/model' export class WalletAddress extends BaseModel @@ -18,6 +19,14 @@ export class WalletAddress } static relationMappings = () => ({ + tenant: { + relation: Model.HasOneRelation, + modelClass: Tenant, + join: { + from: 'walletAddresses.tenantId', + to: 'tenants.id' + } + }, asset: { relation: Model.HasOneRelation, modelClass: Asset, @@ -47,12 +56,14 @@ export class WalletAddress public keys?: WalletAddressKey[] public additionalProperties?: WalletAddressAdditionalProperty[] - public url!: string + public address!: string public publicName?: string public readonly assetId!: string public asset!: Asset + public readonly tenantId!: string + // The cumulative received amount tracked by // `wallet_address.web_monetization` webhook events. // The value should be equivalent to the following query: @@ -113,7 +124,7 @@ export class WalletAddress resourceServer: string }): OpenPaymentsWalletAddress { const returnVal: OpenPaymentsWalletAddress = { - id: this.url, + id: this.address, publicName: this.publicName, assetCode: this.asset.code, assetScale: this.asset.scale, @@ -180,6 +191,7 @@ export interface GetOptions { id: string client?: string walletAddressId?: string + tenantId?: string } export interface ListOptions { @@ -187,6 +199,7 @@ export interface ListOptions { client?: string pagination?: Pagination sortOrder?: SortOrder + tenantId?: string } class SubresourceQueryBuilder< diff --git a/packages/backend/src/open_payments/wallet_address/routes.test.ts b/packages/backend/src/open_payments/wallet_address/routes.test.ts index ee4777f43b..227fc40430 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.test.ts @@ -34,7 +34,7 @@ describe('Wallet Address Routes', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -62,6 +62,7 @@ describe('Wallet Address Routes', (): void => { test('throws 404 error for inactive wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: faker.person.firstName() }) @@ -70,7 +71,7 @@ describe('Wallet Address Routes', (): void => { const ctx = createContext({ headers: { Accept: 'application/json' } }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address const getOrPollByUrlSpy = jest.spyOn( walletAddressService, @@ -102,6 +103,7 @@ describe('Wallet Address Routes', (): void => { addPropNotVisibleInOpenPayments.fieldValue = 'it-is-not' addPropNotVisibleInOpenPayments.visibleInOpenPayments = false const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName(), additionalProperties: [addProp, addPropNotVisibleInOpenPayments] }) @@ -110,16 +112,17 @@ describe('Wallet Address Routes', (): void => { headers: { Accept: 'application/json' }, url: '/' }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address await expect(walletAddressRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ - id: walletAddress.url, + id: walletAddress.address, publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl, + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${config.operatorTenantId}`, + resourceServer: `${config.openPaymentsUrl}/${config.operatorTenantId}`, additionalProperties: { [addProp.fieldKey]: addProp.fieldValue } @@ -145,6 +148,7 @@ describe('Wallet Address Routes', (): void => { test('returns wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName() }) @@ -152,16 +156,17 @@ describe('Wallet Address Routes', (): void => { headers: { Accept: 'application/json' }, url: '/' }) - ctx.walletAddressUrl = walletAddress.url + ctx.walletAddressUrl = walletAddress.address await expect(walletAddressRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ - id: walletAddress.url, + id: walletAddress.address, publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${walletAddress.tenantId}`, + resourceServer: `${config.openPaymentsUrl}/${walletAddress.tenantId}` }) }) }) diff --git a/packages/backend/src/open_payments/wallet_address/routes.ts b/packages/backend/src/open_payments/wallet_address/routes.ts index 652394985e..f3b75c989d 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.ts @@ -11,6 +11,7 @@ import { } from '../../shared/pagination' import { OpenPaymentsServerRouteError } from '../route-errors' import { IAppConfig } from '../../config/app' +import { ensureTrailingSlash } from '../../shared/utils' interface ServiceDependencies { config: IAppConfig @@ -60,8 +61,8 @@ export async function getWalletAddress( ) ctx.body = walletAddress.toOpenPaymentsType({ - authServer: deps.config.authServerGrantUrl, - resourceServer: deps.config.openPaymentsUrl + authServer: `${ensureTrailingSlash(deps.config.authServerGrantUrl)}${walletAddress.tenantId}`, + resourceServer: `${ensureTrailingSlash(deps.config.openPaymentsUrl)}${walletAddress.tenantId}` }) } @@ -94,14 +95,16 @@ export const listSubresource = async ({ const page = await getWalletAddressPage({ walletAddressId: ctx.walletAddress.id, pagination, - client + client, + tenantId: ctx.params.tenantId }) const pageInfo = await getPageInfo({ getPage: (pagination) => getWalletAddressPage({ walletAddressId: ctx.walletAddress.id, pagination, - client + client, + tenantId: ctx.params.tenantId }), page, walletAddress: ctx.request.query['wallet-address'] diff --git a/packages/backend/src/open_payments/wallet_address/service.test.ts b/packages/backend/src/open_payments/wallet_address/service.test.ts index b2b7245010..971c5a2972 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -12,6 +12,7 @@ import { CreateOptions, FORBIDDEN_PATHS, WalletAddressService } from './service' import { AccountingService } from '../../accounting/service' import { createTestApp, TestContainer } from '../../tests/app' import { createAsset } from '../../tests/asset' +import { createTenant } from '../../tests/tenant' import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' import { Config, IAppConfig } from '../../config/app' @@ -26,6 +27,8 @@ import { sleep } from '../../shared/utils' import { withConfigOverride } from '../../tests/helpers' import { WalletAddressAdditionalProperty } from './additional_property/model' import { CacheDataStore } from '../../middleware/cache/data-stores' +import { createTenantSettings } from '../../tests/tenantSettings' +import { TenantSettingKeys } from '../../tenants/settings/model' describe('Open Payments Wallet Address Service', (): void => { let deps: IocContract @@ -49,7 +52,7 @@ describe('Open Payments Wallet Address Service', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -57,13 +60,27 @@ describe('Open Payments Wallet Address Service', (): void => { }) describe('Create or Get Wallet Address', (): void => { + let tenantId: string let options: CreateOptions beforeEach(async (): Promise => { - const { id: assetId } = await createAsset(deps) + tenantId = (await createTenant(deps)).id + const { id: assetId } = await createAsset(deps, { tenantId }) + + await createTenantSettings(deps, { + tenantId: tenantId, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://alice.me' + } + ] + }) + options = { - url: 'https://alice.me/.well-known/pay', - assetId + address: 'https://alice.me/.well-known/pay', + assetId, + tenantId } }) @@ -86,47 +103,124 @@ describe('Open Payments Wallet Address Service', (): void => { } ) - test('Cannot create wallet address with unknown asset', async (): Promise => { - await expect( - walletAddressService.create({ + test.each` + isOperator | tenantSettingUrl + ${false} | ${undefined} + ${true} | ${undefined} + ${true} | ${'https://alice.me'} + `( + 'operator - $isOperator with tenantSettingUrl - $tenantSettingUrl', + async ({ isOperator, tenantSettingUrl }): Promise => { + const address = 'test' + const tempTenant = await createTenant(deps) + const { id: tempAssetId } = await createAsset(deps, { + tenantId: tempTenant.id + }) + + let expected: string = WalletAddressError.WalletAddressSettingNotFound + if (tenantSettingUrl) { + await createTenantSettings(deps, { + tenantId: tempTenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: tenantSettingUrl + } + ] + }) + expected = `${tenantSettingUrl}/${address}` + } else { + if (isOperator) { + expected = `https://op.example/${address}` + } + } + + const created = await walletAddressService.create({ ...options, - assetId: uuid() + address, + isOperator, + assetId: tempAssetId, + tenantId: tempTenant.id }) - ).resolves.toEqual(WalletAddressError.UnknownAsset) + + if (isWalletAddressError(expected)) { + expect(created).toEqual(expected) + } else { + assert.ok(!isWalletAddressError(created)) + expect(created.address).toEqual(expected) + } + } + ) + + test('should return error without tenant settings if caller is not an operator', async () => { + const tempTenant = await createTenant(deps) + + expect( + await walletAddressService.create({ + ...options, + tenantId: tempTenant.id + }) + ).toEqual(WalletAddressError.WalletAddressSettingNotFound) + }) + + test('should return InvalidUrl error if wallet address URL does not start with tenant wallet address URL', async (): Promise => { + const result = await walletAddressService.create({ + ...options, + address: 'https://bob.me/.well-known/pay' + }) + expect(result).toEqual(WalletAddressError.InvalidUrl) }) test.each` - url | description - ${'not a url'} | ${'without a valid url'} - ${'http://alice.me/pay'} | ${'with a non-https url'} - ${'https://alice.me'} | ${'with a url without a path'} - ${'https://alice.me/'} | ${'with a url without a path'} + setting | address | generated + ${'https://alice.me/ilp'} | ${'https://alice.me/ilp/test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp'} | ${'test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp'} | ${'/test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp/'} | ${'test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp/'} | ${'/test'} | ${'https://alice.me/ilp/test'} `( - 'Wallet address cannot be created $description ($url)', - async ({ url }): Promise => { - await expect( - walletAddressService.create({ - ...options, - url - }) - ).resolves.toEqual(WalletAddressError.InvalidUrl) + 'should create address $generated with address $address and setting $setting', + async ({ setting, address, generated }): Promise => { + await createTenantSettings(deps, { + tenantId: tenantId, + setting: [ + { key: TenantSettingKeys.WALLET_ADDRESS_URL.name, value: setting } + ] + }) + + const walletAddress = await walletAddressService.create({ + ...options, + address + }) + + assert.ok(!isWalletAddressError(walletAddress)) + expect(walletAddress.address).toEqual(generated) } ) + test('Cannot create wallet address with unknown asset', async (): Promise => { + await expect( + walletAddressService.create({ + ...options, + assetId: uuid() + }) + ).resolves.toEqual(WalletAddressError.UnknownAsset) + }) + test.each(FORBIDDEN_PATHS.map((path) => [path]))( 'Wallet address cannot be created with forbidden url path (%s)', async (path): Promise => { - const url = `https://alice.me${path}` + const address = `https://alice.me${path}` await expect( walletAddressService.create({ ...options, - url + address }) ).resolves.toEqual(WalletAddressError.InvalidUrl) await expect( walletAddressService.create({ ...options, - url: `${url}/more/path` + address: `${address}/more/path` }) ).resolves.toEqual(WalletAddressError.InvalidUrl) } @@ -141,26 +235,26 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('Creating wallet address with case insensitiveness', async (): Promise => { - const url = 'https://Alice.me/pay' + const address = 'https://Alice.me/pay' await expect( walletAddressService.create({ ...options, - url + address }) - ).resolves.toMatchObject({ url: url.toLowerCase() }) + ).resolves.toMatchObject({ address: address.toLowerCase() }) }) test('Wallet address cannot be created if the url is duplicated', async (): Promise => { - const url = 'https://Alice.me/pay' + const address = 'https://Alice.me/pay' const wallet = walletAddressService.create({ ...options, - url + address }) assert.ok(!isWalletAddressError(wallet)) await expect( walletAddressService.create({ ...options, - url + address }) ).resolves.toEqual(WalletAddressError.DuplicateWalletAddress) }) @@ -176,7 +270,9 @@ describe('Open Payments Wallet Address Service', (): void => { `( 'Wallet address with initial isActive of $initialIsActive can be updated with $status status ', async ({ initialIsActive, status, expectedIsActive }): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (!initialIsActive) { await walletAddress.$query(knex).patch({ deactivatedAt: new Date() }) @@ -198,6 +294,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('publicName', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name' }) const newName = 'New Name' @@ -223,7 +320,9 @@ describe('Open Payments Wallet Address Service', (): void => { incomingPaymentExpiryMaxMs: 2592000000 * 3 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -242,7 +341,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ @@ -266,7 +366,9 @@ describe('Open Payments Wallet Address Service', (): void => { () => config, { walletAddressDeactivationPaymentGracePeriodMs: 2592000000 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -284,7 +386,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ @@ -302,6 +405,7 @@ describe('Open Payments Wallet Address Service', (): void => { describe('additionalProperties', (): void => { test('should do nothing if additionalProperties is undefined', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name', additionalProperties: [ { @@ -331,6 +435,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('should update to [] (deleting all) when additionalProperties is []', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -363,6 +468,7 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('should replace existing additionalProperties', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -424,17 +530,19 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Wallet Address By Url', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( - walletAddressService.getByUrl(walletAddress.url) + walletAddressService.getByUrl(walletAddress.address) ).resolves.toEqual(walletAddress) await expect( - walletAddressService.getByUrl(walletAddress.url + '/path') + walletAddressService.getByUrl(walletAddress.address + '/path') ).resolves.toBeUndefined() await expect( - walletAddressService.getByUrl('prefix+' + walletAddress.url) + walletAddressService.getByUrl('prefix+' + walletAddress.address) ).resolves.toBeUndefined() }) @@ -455,16 +563,18 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Or Poll Wallet Addres By Url', (): void => { describe('existing wallet address', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( - walletAddressService.getOrPollByUrl(walletAddress.url) + walletAddressService.getOrPollByUrl(walletAddress.address) ).resolves.toEqual(walletAddress) }) }) describe('non-existing wallet address', (): void => { test( - 'creates wallet address not found event', + 'creates wallet address not found event for operator when no matching tenant prefix', withConfigOverride( () => config, { walletAddressLookupTimeoutMs: 0 }, @@ -476,12 +586,67 @@ describe('Open Payments Wallet Address Service', (): void => { const walletAddressNotFoundEvents = await WalletAddressEvent.query( knex - ).where({ - type: WalletAddressEventType.WalletAddressNotFound + ) + .where({ + type: WalletAddressEventType.WalletAddressNotFound + }) + .withGraphFetched('webhooks') + + expect(walletAddressNotFoundEvents[0]).toMatchObject({ + data: { walletAddressUrl }, + webhooks: [ + expect.objectContaining({ + recipientTenantId: config.operatorTenantId, + eventId: walletAddressNotFoundEvents[0].id, + processAt: expect.any(Date) + }) + ] + }) + } + ) + ) + + test( + 'creates wallet address not found event for tenant with matching prefix', + withConfigOverride( + () => config, + { walletAddressLookupTimeoutMs: 0 }, + async (): Promise => { + const walletAddressUrl = `https://${faker.internet.domainName()}/.well-known/pay` + const tenant = await createTenant(deps) + await createTenantSettings(deps, { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: `${walletAddressUrl}/${uuid()}` + } + ] }) + await expect( + walletAddressService.getOrPollByUrl(walletAddressUrl) + ).resolves.toBeUndefined() + + const walletAddressNotFoundEvents = await WalletAddressEvent.query( + knex + ) + .where({ + type: WalletAddressEventType.WalletAddressNotFound + }) + .withGraphFetched('webhooks') + + expect(walletAddressNotFoundEvents).toHaveLength(1) + expect(walletAddressNotFoundEvents[0].webhooks).toHaveLength(1) expect(walletAddressNotFoundEvents[0]).toMatchObject({ - data: { walletAddressUrl } + data: { walletAddressUrl }, + webhooks: expect.arrayContaining([ + expect.objectContaining({ + recipientTenantId: tenant.id, + eventId: walletAddressNotFoundEvents[0].id, + processAt: expect.any(Date) + }) + ]) }) } ) @@ -501,7 +666,8 @@ describe('Open Payments Wallet Address Service', (): void => { (async () => { await sleep(5) return createWalletAddress(deps, { - url: walletAddressUrl + tenantId: Config.operatorTenantId, + address: walletAddressUrl }) })() ]) @@ -530,7 +696,8 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Wallet Address pagination', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => walletAddressService.getPage(pagination, sortOrder) }) @@ -541,7 +708,9 @@ describe('Open Payments Wallet Address Service', (): void => { let walletAddress: WalletAddress beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) describe.each` @@ -661,6 +830,7 @@ describe('Open Payments Wallet Address Service', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) }) @@ -713,14 +883,22 @@ describe('Open Payments Wallet Address Service', (): void => { processAt: null, totalEventsAmount: totalEventsAmount + withdrawalAmount }) - await expect( - WalletAddressEvent.query(knex).where({ + const events = await WalletAddressEvent.query(knex) + .where({ type: WalletAddressEventType.WalletAddressWebMonetization, withdrawalAccountId: walletAddress.id, withdrawalAssetId: walletAddress.assetId, withdrawalAmount }) - ).resolves.toHaveLength(1) + .withGraphFetched('webhooks') + expect(events).toHaveLength(1) + expect(events[0].webhooks).toEqual([ + expect.objectContaining({ + recipientTenantId: walletAddress.tenantId, + eventId: events[0].id, + processAt: expect.any(Date) + }) + ]) } ) }) @@ -734,6 +912,7 @@ describe('Open Payments Wallet Address Service', (): void => { for (let i = 0; i < 5; i++) { walletAddresses.push( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId, createLiquidityAccount: true }) @@ -822,7 +1001,7 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -845,7 +1024,9 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { expectedCallCount }): Promise => { const spyCacheSet = jest.spyOn(walletAddressCache, 'set') - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) expect(spyCacheSet).toHaveBeenCalledTimes(1) if (!initialIsActive) { @@ -878,7 +1059,7 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { walletAddress.id, expect.objectContaining({ id: walletAddress.id, - url: walletAddress.url + address: walletAddress.address }) ) diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index de663b6ff7..f4d0768d90 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -6,7 +6,7 @@ import { } from 'objection' import { URL } from 'url' -import { WalletAddressError } from './errors' +import { isWalletAddressError, WalletAddressError } from './errors' import { WalletAddress, WalletAddressEvent, @@ -23,11 +23,16 @@ import { } from '../payment/incoming/model' import { IAppConfig } from '../../config/app' import { Pagination, SortOrder } from '../../shared/baseModel' -import { WebhookService } from '../../webhook/service' +import { + finalizeWebhookRecipients, + WebhookService +} from '../../webhook/service' import { poll } from '../../shared/utils' import { WalletAddressAdditionalProperty } from './additional_property/model' import { AssetService } from '../../asset/service' import { CacheDataStore } from '../../middleware/cache/data-stores' +import { TenantSettingKeys } from '../../tenants/settings/model' +import { TenantSettingService } from '../../tenants/settings/service' interface Options { publicName?: string @@ -39,9 +44,11 @@ export type WalletAddressAdditionalPropertyInput = Pick< > export interface CreateOptions extends Options { - url: string + tenantId: string + address: string assetId: string additionalProperties?: WalletAddressAdditionalPropertyInput[] + isOperator?: boolean } type Status = 'ACTIVE' | 'INACTIVE' @@ -64,12 +71,13 @@ export interface WalletAddressService { id: string, includeVisibleOnlyAddProps: boolean ): Promise - get(id: string): Promise - getByUrl(url: string): Promise + get(id: string, tenantId?: string): Promise + getByUrl(url: string, tenantId?: string): Promise getOrPollByUrl(url: string): Promise getPage( pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise processNext(): Promise triggerEvents(limit: number): Promise @@ -82,6 +90,7 @@ interface ServiceDependencies extends BaseService { webhookService: WebhookService assetService: AssetService walletAddressCache: CacheDataStore + tenantSettingService: TenantSettingService } export async function createWalletAddressService({ @@ -91,7 +100,8 @@ export async function createWalletAddressService({ accountingService, webhookService, assetService, - walletAddressCache + walletAddressCache, + tenantSettingService }: ServiceDependencies): Promise { const log = logger.child({ service: 'WalletAddressService' @@ -103,7 +113,8 @@ export async function createWalletAddressService({ accountingService, webhookService, assetService, - walletAddressCache + walletAddressCache, + tenantSettingService } return { create: (options) => createWalletAddress(deps, options), @@ -114,11 +125,11 @@ export async function createWalletAddressService({ walletAddressId, includeVisibleOnlyAddProps ), - get: (id) => getWalletAddress(deps, id), - getByUrl: (url) => getWalletAddressByUrl(deps, url), + get: (id, tenantId) => getWalletAddress(deps, id, tenantId), + getByUrl: (url, tenantId) => getWalletAddressByUrl(deps, url, tenantId), getOrPollByUrl: (url) => getOrPollByUrl(deps, url), - getPage: (pagination?, sortOrder?) => - getWalletAddressPage(deps, pagination, sortOrder), + getPage: (pagination?, sortOrder?, tenantId?) => + getWalletAddressPage(deps, pagination, sortOrder, tenantId), processNext: () => processNextWalletAddress(deps), triggerEvents: (limit) => triggerWalletAddressEvents(deps, limit) } @@ -159,15 +170,82 @@ function cleanAdditionalProperties( .filter((prop) => prop.fieldKey.length > 0 && prop.fieldValue.length > 0) } +async function createWalletAddressUrl( + deps: ServiceDependencies, + options: CreateOptions +): Promise { + let tenantWalletAddressUrl = new URL(deps.config.openPaymentsUrl) + + const found = await deps.tenantSettingService.get({ + tenantId: options.tenantId, + key: TenantSettingKeys.WALLET_ADDRESS_URL.name + }) + + if (!found || found.length === 0) { + if (!options.isOperator) { + return WalletAddressError.WalletAddressSettingNotFound + } + } else { + tenantWalletAddressUrl = new URL(found[0].value) + } + + let tenantBaseUrl = tenantWalletAddressUrl.toString() + if (!tenantWalletAddressUrl.pathname.endsWith('/')) { + tenantBaseUrl = + tenantWalletAddressUrl.origin + tenantWalletAddressUrl.pathname + '/' + } + + const isValidUrl = (str: string): boolean => { + try { + new URL(str) + return true + } catch { + return false + } + } + + let finalWalletAddressUrl: string + if (isValidUrl(options.address)) { + // in case that client provided full url, verify that it starts with the tenant's URL + const walletAddressUrl = new URL(options.address) + if (!walletAddressUrl.href.startsWith(tenantWalletAddressUrl.href)) { + return WalletAddressError.InvalidUrl + } + finalWalletAddressUrl = walletAddressUrl.toString() + } else { + // in case that client provided just the path / wallet address name, construct the address using the wallet address url from tenant setting + try { + let relativePath = options.address + if (relativePath.startsWith('/')) { + relativePath = relativePath.substring(1) + } + finalWalletAddressUrl = tenantBaseUrl + relativePath + } catch (err) { + return WalletAddressError.InvalidUrl + } + } + + if (!isValidWalletAddressUrl(finalWalletAddressUrl)) { + return WalletAddressError.InvalidUrl + } + + return finalWalletAddressUrl +} + async function createWalletAddress( deps: ServiceDependencies, options: CreateOptions ): Promise { - if (!isValidWalletAddressUrl(options.url)) { - return WalletAddressError.InvalidUrl + const finalWalletAddressUrl = await createWalletAddressUrl(deps, options) + + if (isWalletAddressError(finalWalletAddressUrl)) { + return finalWalletAddressUrl } try { + const asset = await deps.assetService.get(options.assetId, options.tenantId) + if (!asset) return WalletAddressError.UnknownAsset + // Remove blank key/value pairs: const additionalProperties = options.additionalProperties ? cleanAdditionalProperties(options.additionalProperties) @@ -176,13 +254,13 @@ async function createWalletAddress( const walletAddress = await WalletAddress.query( deps.knex ).insertGraphAndFetch({ - url: options.url.toLowerCase(), + tenantId: options.tenantId, + address: finalWalletAddressUrl.toLowerCase(), publicName: options.publicName, - assetId: options.assetId, + assetId: asset.id, additionalProperties: additionalProperties }) - const asset = await deps.assetService.get(walletAddress.assetId) - if (asset) walletAddress.asset = asset + walletAddress.asset = asset await deps.walletAddressCache.set(walletAddress.id, walletAddress) return walletAddress @@ -260,12 +338,18 @@ async function updateWalletAddress( async function getWalletAddress( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { - const walletAdd = await deps.walletAddressCache.get(id) - if (walletAdd) return walletAdd + const inMem = await deps.walletAddressCache.get(id) + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) - const walletAddress = await WalletAddress.query(deps.knex).findById(id) + const walletAddress = await query.findById(id) if (walletAddress) { const asset = await deps.assetService.get(walletAddress.assetId) if (asset) walletAddress.asset = asset @@ -298,11 +382,21 @@ async function getOrPollByUrl( const existingWalletAddress = await getWalletAddressByUrl(deps, url) if (existingWalletAddress) return existingWalletAddress - await WalletAddressEvent.query(deps.knex).insert({ + const webhookRecipients = ( + await deps.tenantSettingService.getSettingsByPrefix(url) + ).map((tenantSetting) => tenantSetting.tenantId) + + if (!webhookRecipients.length) { + webhookRecipients.push(deps.config.operatorTenantId) + } + + await WalletAddressEvent.query(deps.knex).insertGraph({ type: WalletAddressEventType.WalletAddressNotFound, data: { walletAddressUrl: url - } + }, + tenantId: deps.config.operatorTenantId, + webhooks: finalizeWebhookRecipients(webhookRecipients, deps.config) }) deps.logger.debug( @@ -323,10 +417,14 @@ async function getOrPollByUrl( async function getWalletAddressByUrl( deps: ServiceDependencies, - url: string + url: string, + tenantId?: string ): Promise { - const walletAddress = await WalletAddress.query(deps.knex).findOne({ - url: url.toLowerCase() + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) + + const walletAddress = await query.findOne({ + address: url.toLowerCase() }) if (walletAddress) { const asset = await deps.assetService.get(walletAddress.assetId) @@ -338,11 +436,18 @@ async function getWalletAddressByUrl( async function getWalletAddressPage( deps: ServiceDependencies, pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise { - return await WalletAddress.query(deps.knex) - .getPage(pagination, sortOrder) - .withGraphFetched('asset') + const query = WalletAddress.query(deps.knex) + if (tenantId) query.where({ tenantId }) + + const addresses = await query.getPage(pagination, sortOrder) + for (const address of addresses) { + const asset = await deps.assetService.get(address.assetId) + if (asset) address.asset = asset + } + return addresses } // Returns the id of the processed wallet address (if any). @@ -428,7 +533,7 @@ async function createWithdrawalEvent( deps.logger.trace({ amount }, 'creating webhook withdrawal event') - await WalletAddressEvent.query(deps.knex).insert({ + await WalletAddressEvent.query(deps.knex).insertGraph({ walletAddressId: walletAddress.id, type: WalletAddressEventType.WalletAddressWebMonetization, data: walletAddress.toData(amount), @@ -436,7 +541,9 @@ async function createWithdrawalEvent( accountId: walletAddress.id, assetId: walletAddress.assetId, amount - } + }, + tenantId: walletAddress.tenantId, + webhooks: finalizeWebhookRecipients([walletAddress.tenantId], deps.config) }) await walletAddress.$query(deps.knex).patch({ diff --git a/packages/backend/src/payment-method/handler/service.test.ts b/packages/backend/src/payment-method/handler/service.test.ts index 254963967c..519904d0d4 100644 --- a/packages/backend/src/payment-method/handler/service.test.ts +++ b/packages/backend/src/payment-method/handler/service.test.ts @@ -36,7 +36,7 @@ describe('PaymentMethodHandlerService', (): void => { afterEach(async (): Promise => { jest.restoreAllMocks() - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -45,8 +45,10 @@ describe('PaymentMethodHandlerService', (): void => { describe('getQuote', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) @@ -73,8 +75,10 @@ describe('PaymentMethodHandlerService', (): void => { ) }) test('calls localPaymentService for local payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) @@ -103,8 +107,10 @@ describe('PaymentMethodHandlerService', (): void => { describe('pay', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -113,6 +119,7 @@ describe('PaymentMethodHandlerService', (): void => { receivingWalletAddress: walletAddress, method: 'ilp', quoteOptions: { + tenantId, debitAmount: { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, @@ -137,8 +144,10 @@ describe('PaymentMethodHandlerService', (): void => { expect(ilpPaymentServicePaySpy).toHaveBeenCalledWith(options) }) test('calls localPaymentService for local payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -147,6 +156,7 @@ describe('PaymentMethodHandlerService', (): void => { receivingWalletAddress: walletAddress, method: 'ilp', quoteOptions: { + tenantId, debitAmount: { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, diff --git a/packages/backend/src/payment-method/ilp/auto-peering/routes.test.ts b/packages/backend/src/payment-method/ilp/auto-peering/routes.test.ts index 2a5f16b2a7..94854d04a5 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/routes.test.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/routes.test.ts @@ -23,7 +23,7 @@ describe('Auto Peering Routes', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -55,7 +55,8 @@ describe('Auto Peering Routes', (): void => { staticIlpAddress: config.ilpAddress, ilpConnectorUrl: config.ilpConnectorUrl, httpToken: expect.any(String), - name: config.instanceName + name: config.instanceName, + tenantId: config.operatorTenantId }) }) diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts index af2c2e2282..745fe63420 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.test.ts @@ -27,6 +27,7 @@ describe('Auto Peering Service', (): void => { let autoPeeringService: AutoPeeringService let peerService: PeerService let accountingService: AccountingService + let tenantId: string beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, enableAutoPeering: true }) @@ -35,10 +36,11 @@ describe('Auto Peering Service', (): void => { autoPeeringService = await deps.use('autoPeeringService') peerService = await deps.use('peerService') accountingService = await deps.use('accountingService') + tenantId = Config.operatorTenantId }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -55,7 +57,8 @@ describe('Auto Peering Service', (): void => { asset: { code: asset.code, scale: asset.scale }, httpToken: 'someHttpToken', name: 'Rafiki Money', - maxPacketAmount: 1000 + maxPacketAmount: 1000, + tenantId } const peerCreateSpy = jest.spyOn(peerService, 'create') @@ -66,7 +69,8 @@ describe('Auto Peering Service', (): void => { staticIlpAddress: config.ilpAddress, ilpConnectorUrl: config.ilpConnectorUrl, httpToken: expect.any(String), - name: config.instanceName + name: config.instanceName, + tenantId }) expect(peerCreateSpy).toHaveBeenCalledWith({ staticIlpAddress: args.staticIlpAddress, @@ -80,7 +84,8 @@ describe('Auto Peering Service', (): void => { authToken: expect.any(String), endpoint: args.ilpConnectorUrl } - } + }, + tenantId }) }) @@ -92,28 +97,31 @@ describe('Auto Peering Service', (): void => { ilpConnectorUrl: 'http://peer.rafiki.money', asset: { code: asset.code, scale: asset.scale }, httpToken: 'someHttpToken', - name: 'Rafiki Money' + name: 'Rafiki Money', + tenantId } const peerUpdateSpy = jest.spyOn(peerService, 'update') await expect( autoPeeringService.acceptPeeringRequest(args) - ).resolves.toEqual({ + ).resolves.toMatchObject({ staticIlpAddress: config.ilpAddress, ilpConnectorUrl: config.ilpConnectorUrl, httpToken: expect.any(String), - name: config.instanceName + name: config.instanceName, + tenantId }) expect(peerUpdateSpy).toHaveBeenCalledTimes(0) await expect( autoPeeringService.acceptPeeringRequest(args) - ).resolves.toEqual({ + ).resolves.toMatchObject({ staticIlpAddress: config.ilpAddress, ilpConnectorUrl: config.ilpConnectorUrl, httpToken: expect.any(String), - name: config.instanceName + name: config.instanceName, + tenantId }) expect(peerUpdateSpy).toHaveBeenCalledWith({ id: expect.any(String), @@ -124,7 +132,8 @@ describe('Auto Peering Service', (): void => { authToken: expect.any(String), endpoint: args.ilpConnectorUrl } - } + }, + tenantId }) expect(peerUpdateSpy).toHaveBeenCalledTimes(1) }) @@ -134,7 +143,8 @@ describe('Auto Peering Service', (): void => { staticIlpAddress: 'test.rafiki-money', ilpConnectorUrl: 'http://peer.rafiki.money', asset: { code: 'USD', scale: 2 }, - httpToken: 'someHttpToken' + httpToken: 'someHttpToken', + tenantId } await expect(autoPeeringService.acceptPeeringRequest(args)).resolves.toBe( @@ -149,7 +159,8 @@ describe('Auto Peering Service', (): void => { staticIlpAddress: 'test.rafiki-money', ilpConnectorUrl: 'invalid', asset: { code: asset.code, scale: asset.scale }, - httpToken: 'someHttpToken' + httpToken: 'someHttpToken', + tenantId } await expect( @@ -164,7 +175,8 @@ describe('Auto Peering Service', (): void => { staticIlpAddress: 'invalid', ilpConnectorUrl: 'http://peer.rafiki.money', asset: { code: asset.code, scale: asset.scale }, - httpToken: 'someHttpToken' + httpToken: 'someHttpToken', + tenantId } await expect( @@ -179,7 +191,8 @@ describe('Auto Peering Service', (): void => { staticIlpAddress: 'test.rafiki-money', ilpConnectorUrl: 'http://peer.rafiki.money', asset: { code: asset.code, scale: asset.scale }, - httpToken: 'someHttpToken' + httpToken: 'someHttpToken', + tenantId } assert.ok( @@ -199,7 +212,8 @@ describe('Auto Peering Service', (): void => { test('returns error if unknown asset', async (): Promise => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: uuid() + assetId: uuid(), + tenantId } await expect( @@ -214,14 +228,16 @@ describe('Auto Peering Service', (): void => { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, maxPacketAmount: 1000n, - liquidityThreshold: 100n + liquidityThreshold: 100n, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl) @@ -261,7 +277,8 @@ describe('Auto Peering Service', (): void => { }, maxPacketAmount: args.maxPacketAmount, name: peerDetails.name, - liquidityThreshold: args.liquidityThreshold + liquidityThreshold: args.liquidityThreshold, + tenantId }) scope.done() @@ -275,14 +292,16 @@ describe('Auto Peering Service', (): void => { assetId: asset.id, maxPacketAmount: 1000n, liquidityThreshold: 100n, - liquidityToDeposit: 10000n + liquidityToDeposit: 10000n, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl) @@ -321,14 +340,16 @@ describe('Auto Peering Service', (): void => { assetId: asset.id, maxPacketAmount: 1000n, liquidityThreshold: 100n, - liquidityToDeposit: -10000n + liquidityToDeposit: -10000n, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl) @@ -361,14 +382,16 @@ describe('Auto Peering Service', (): void => { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, maxPacketAmount: 1000n, - name: 'Overridden Peer Name' + name: 'Overridden Peer Name', + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').reply(200, peerDetails) @@ -392,7 +415,8 @@ describe('Auto Peering Service', (): void => { } }, maxPacketAmount: args.maxPacketAmount, - name: args.name + name: args.name, + tenantId }) scope.done() @@ -403,7 +427,8 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: `http://${uuid()}.test`, - assetId: asset.id + assetId: asset.id, + tenantId } await expect( @@ -416,7 +441,8 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const scope = nock(args.peerUrl) @@ -424,7 +450,6 @@ describe('Auto Peering Service', (): void => { .reply(400, { error: { type: AutoPeeringError.InvalidPeerIlpConfiguration } }) - await expect( autoPeeringService.initiatePeeringRequest(args) ).resolves.toBe(AutoPeeringError.InvalidIlpConfiguration) @@ -433,18 +458,16 @@ describe('Auto Peering Service', (): void => { test('returns error if peer does not support asset', async (): Promise => { const asset = await createAsset(deps) - const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } - const scope = nock(args.peerUrl) .post('/') .reply(400, { error: { type: AutoPeeringError.UnknownAsset } }) - await expect( autoPeeringService.initiatePeeringRequest(args) ).resolves.toBe(AutoPeeringError.PeerUnsupportedAsset) @@ -453,14 +476,13 @@ describe('Auto Peering Service', (): void => { test('returns error if peer URL request error', async (): Promise => { const asset = await createAsset(deps) - const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const scope = nock(args.peerUrl).post('/').replyWithError('some error') - await expect( autoPeeringService.initiatePeeringRequest(args) ).resolves.toBe(AutoPeeringError.InvalidPeeringRequest) @@ -472,14 +494,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: '', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').reply(200, peerDetails) @@ -495,14 +519,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: '', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').reply(200, peerDetails) @@ -518,14 +544,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: uuid(), - name: 'Peer 2' + name: 'Peer 2', + tenantId } const secondPeerDetails: PeeringDetails = { @@ -574,14 +602,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, - liquidityToDeposit: 1000n + liquidityToDeposit: 1000n, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: uuid(), - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').twice().reply(200, peerDetails) @@ -612,14 +642,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', assetId: asset.id, - liquidityToDeposit: 1000n + liquidityToDeposit: 1000n, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: uuid(), - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').twice().reply(200, peerDetails) @@ -644,14 +676,16 @@ describe('Auto Peering Service', (): void => { const args: InitiatePeeringRequestArgs = { peerUrl: 'http://peer.rafiki.money', - assetId: asset.id + assetId: asset.id, + tenantId } const peerDetails: PeeringDetails = { staticIlpAddress: 'test.peer2', ilpConnectorUrl: 'http://peer-two.com', httpToken: 'peerHttpToken', - name: 'Peer 2' + name: 'Peer 2', + tenantId } const scope = nock(args.peerUrl).post('/').reply(200, peerDetails) diff --git a/packages/backend/src/payment-method/ilp/auto-peering/service.ts b/packages/backend/src/payment-method/ilp/auto-peering/service.ts index 31d1e60ea9..4463c2514e 100644 --- a/packages/backend/src/payment-method/ilp/auto-peering/service.ts +++ b/packages/backend/src/payment-method/ilp/auto-peering/service.ts @@ -14,6 +14,7 @@ export interface PeeringDetails { ilpConnectorUrl: string httpToken: string name: string + tenantId: string } export interface InitiatePeeringRequestArgs { @@ -23,6 +24,7 @@ export interface InitiatePeeringRequestArgs { maxPacketAmount?: bigint liquidityToDeposit?: bigint liquidityThreshold?: bigint + tenantId: string } export interface PeeringRequestArgs { @@ -32,6 +34,7 @@ export interface PeeringRequestArgs { httpToken: string maxPacketAmount?: number name?: string + tenantId?: string } interface UpdatePeerArgs { @@ -42,6 +45,7 @@ interface UpdatePeerArgs { outgoingHttpToken: string maxPacketAmount?: number name?: string + tenantId: string } interface DepositLiquidityArgs { @@ -122,7 +126,8 @@ async function initiatePeeringRequest( authToken: outgoingHttpToken, endpoint: peeringDetailsOrError.ilpConnectorUrl } - } + }, + tenantId: args.tenantId }) const isDuplicatePeer = @@ -139,7 +144,8 @@ async function initiatePeeringRequest( assetId: asset.id, outgoingHttpToken, incomingHttpToken: peeringDetailsOrError.httpToken, - ilpConnectorUrl: peeringDetailsOrError.ilpConnectorUrl + ilpConnectorUrl: peeringDetailsOrError.ilpConnectorUrl, + tenantId: args.tenantId }) : createdPeerOrError @@ -161,7 +167,8 @@ async function depositLiquidity( ): Promise { const transferOrPeerError = await deps.peerService.depositLiquidity({ peerId: args.peer.id, - amount: args.amount + amount: args.amount, + tenantId: args.peer.tenantId }) if ( @@ -247,7 +254,8 @@ async function acceptPeeringRequest( authToken: outgoingHttpToken } }, - initialLiquidity: BigInt(Number.MAX_SAFE_INTEGER) + initialLiquidity: BigInt(Number.MAX_SAFE_INTEGER), + tenantId: deps.config.operatorTenantId }) const isDuplicatePeeringRequest = @@ -262,7 +270,8 @@ async function acceptPeeringRequest( assetId: asset.id, outgoingHttpToken, incomingHttpToken: args.httpToken, - ilpConnectorUrl: args.ilpConnectorUrl + ilpConnectorUrl: args.ilpConnectorUrl, + tenantId: deps.config.operatorTenantId }) : createdPeerOrError @@ -278,7 +287,8 @@ async function acceptPeeringRequest( ilpConnectorUrl: deps.config.ilpConnectorUrl, staticIlpAddress: deps.config.ilpAddress, httpToken: peerOrError.http.outgoing.authToken, - name: deps.config.instanceName + name: deps.config.instanceName, + tenantId: peerOrError.tenantId } } @@ -288,6 +298,7 @@ async function updatePeer( ): Promise { const peer = await deps.peerService.getByDestinationAddress( args.staticIlpAddress, + args.tenantId, args.assetId ) @@ -308,7 +319,8 @@ async function updatePeer( authToken: args.outgoingHttpToken, endpoint: args.ilpConnectorUrl } - } + }, + tenantId: args.tenantId }) } diff --git a/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts b/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts index bdc2937fd9..c6fb86b21f 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/controllers/stream.ts @@ -1,5 +1,6 @@ import { isIlpReply } from 'ilp-packet' import { ILPContext, ILPMiddleware } from '../rafiki' +import { StreamState } from '../middleware' const CONNECTION_EXPIRY = 60 * 10 // seconds @@ -9,21 +10,22 @@ export const streamReceivedKey = (connectionId: string): string => export function createStreamController(): ILPMiddleware { return async function ( - ctx: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise { - const { logger, redis, streamServer } = ctx.services + const { logger, redis } = ctx.services const { request, response } = ctx if ( ctx.accounts.outgoing.http || - !streamServer.decodePaymentTag(request.prepare.destination) // XXX mark this earlier in the middleware pipeline + !ctx.state.streamDestination || + !ctx.state.streamServer ) { await next() return } - const moneyOrReply = streamServer.createReply(request.prepare) + const moneyOrReply = ctx.state.streamServer.createReply(request.prepare) if (isIlpReply(moneyOrReply)) { response.reply = moneyOrReply return diff --git a/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts b/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts index d1aa2392d8..1d26243141 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/factories/rafiki-services.ts @@ -1,11 +1,10 @@ -import * as crypto from 'crypto' import { Factory } from 'rosie' import { Redis } from 'ioredis' -import { StreamServer } from '@interledger/stream-receiver' import { RafikiServices } from '../rafiki' import { MockAccountingService } from '../test/mocks/accounting-service' import { TestLoggerFactory } from './test-logger' import { MockTelemetryService } from '../../../../../tests/telemetry' +import { Config } from '../../../../../config/app' interface MockRafikiServices extends RafikiServices { accounting: MockAccountingService @@ -19,6 +18,9 @@ export const RafikiServicesFactory = Factory.define( // return new InMemoryRouter(peers, { ilpAddress: 'test.rafiki' }) //}) .option('ilpAddress', 'test.rafiki') + .attr('config', () => { + return Config + }) .attr('accounting', () => { return new MockAccountingService() }) @@ -42,6 +44,26 @@ export const RafikiServicesFactory = Factory.define( } }) ) + .attr('tenantSettingService', () => ({ + get: async () => { + return [] + }, + create: async () => { + throw new Error('unimplemented') + }, + update: async () => { + throw new Error('unimplemented') + }, + delete: async () => { + throw new Error('unimplemented') + }, + getPage: async () => { + throw new Error('unimplemented') + }, + getSettingsByPrefix: async () => { + throw new Error('unimplemented') + } + })) .attr('peers', ['accounting'], (accounting: MockAccountingService) => ({ getByDestinationAddress: async (address: string) => await accounting._getByDestinationAddress(address), @@ -70,12 +92,3 @@ export const RafikiServicesFactory = Factory.define( stringNumbers: true }) ) - .attr( - 'streamServer', - ['ilpAddress'], - (ilpAddress: string) => - new StreamServer({ - serverAddress: ilpAddress, - serverSecret: crypto.randomBytes(32) - }) - ) diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts index e918aec4ba..35a2d3551d 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/account.ts @@ -2,7 +2,6 @@ import { Errors } from 'ilp-packet' import { AccountAlreadyExistsError } from '../../../../../accounting/errors' import { LiquidityAccountType } from '../../../../../accounting/service' import { IncomingPaymentState } from '../../../../../open_payments/payment/incoming/model' -import { validateId } from '../../../../../shared/utils' import { ILPContext, ILPMiddleware, @@ -10,12 +9,11 @@ import { OutgoingAccount } from '../rafiki' import { AuthState } from './auth' +import { StreamState } from './stream-address' -const UUID_LENGTH = 36 - -export function createAccountMiddleware(serverAddress: string): ILPMiddleware { +export function createAccountMiddleware(): ILPMiddleware { return async function account( - ctx: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise { const createLiquidityAccount = async ( @@ -104,24 +102,13 @@ export function createAccountMiddleware(serverAddress: string): ILPMiddleware { } } const address = ctx.request.prepare.destination - const peer = await peers.getByDestinationAddress(address) + const peer = await peers.getByDestinationAddress( + address, + incomingAccount.tenantId + ) if (peer) { return peer } - if ( - address.startsWith(serverAddress + '.') && - (address.length === serverAddress.length + 1 + UUID_LENGTH || - address[serverAddress.length + 1 + UUID_LENGTH] === '.') - ) { - const accountId = address.slice( - serverAddress.length + 1, - serverAddress.length + 1 + UUID_LENGTH - ) - if (validateId(accountId)) { - // TODO: Look up direct ILP access account - // return await accounts.get(accountId) - } - } } const outgoingAccount = await getAccountByDestinationAddress() diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts index 5023b3d6cd..7574ff6bdf 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/balance.ts @@ -40,11 +40,14 @@ export function createBalanceMiddleware(): ILPMiddleware { } const sourceAmount = BigInt(amount) - const destinationAmountOrError = await services.rates.convertSource({ - sourceAmount, - sourceAsset: accounts.incoming.asset, - destinationAsset: accounts.outgoing.asset - }) + const destinationAmountOrError = await services.rates.convertSource( + { + sourceAmount, + sourceAsset: accounts.incoming.asset, + destinationAsset: accounts.outgoing.asset + }, + accounts.outgoing.tenantId + ) if (isConvertError(destinationAmountOrError)) { logger.error( { diff --git a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts index 17b7ba5869..df52b5de1d 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/middleware/stream-address.ts @@ -1,20 +1,61 @@ +import { StreamServer } from '@interledger/stream-receiver' import { ILPMiddleware, ILPContext } from '../rafiki' +import { TenantSettingKeys } from '../../../../../tenants/settings/model' +import { AuthState } from './auth' + +export interface StreamState { + streamDestination?: string + streamServer?: StreamServer +} export function createStreamAddressMiddleware(): ILPMiddleware { return async ( - { request, services: { streamServer, telemetry }, state }: ILPContext, + ctx: ILPContext, next: () => Promise ): Promise => { - const stopTimer = telemetry.startTimer( + const stopTimer = ctx.services.telemetry.startTimer( 'create_stream_address_middleware_decode_tag', { callName: 'createStreamAddressMiddleware:decodePaymentTag' } ) - const { destination } = request.prepare - // To preserve sender privacy, the accountId wasn't included in the original destination address. - state.streamDestination = streamServer.decodePaymentTag(destination) + + const tenantIlpAddress = await getIlpAddressForTenant(ctx) + + ctx.state.streamServer = tenantIlpAddress + ? new StreamServer({ + serverSecret: ctx.services.config.streamSecret, + serverAddress: tenantIlpAddress + }) + : undefined + + ctx.state.streamDestination = + ctx.state.streamServer?.decodePaymentTag( + ctx.request.prepare.destination + ) || undefined + stopTimer() await next() } } + +export async function getIlpAddressForTenant( + ctx: ILPContext +): Promise { + const tenantId = ctx.state.incomingAccount?.tenantId + + if (!tenantId) { + return undefined + } + + if (tenantId === ctx.services.config.operatorTenantId) { + return ctx.services.config.ilpAddress + } + + const tenantSettings = await ctx.services.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + + return tenantSettings.length > 0 ? tenantSettings[0].value : undefined +} diff --git a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts index 6740d21886..ddbedb29ce 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/rafiki.ts @@ -1,6 +1,5 @@ import * as http from 'http' /* eslint-disable @typescript-eslint/no-explicit-any */ -import { StreamServer } from '@interledger/stream-receiver' import { Errors } from 'ilp-packet' import { Redis } from 'ioredis' import Koa, { Middleware } from 'koa' @@ -26,6 +25,8 @@ import { incrementFulfillOrRejectPacketCount, incrementAmount } from './telemetry' +import { IAppConfig } from '../../../../config/app' +import { TenantSettingService } from '../../../../tenants/settings/service' // Model classes that represent an Interledger sender, receiver, or // connector SHOULD implement this ConnectorAccount interface. @@ -36,6 +37,7 @@ import { // ../../peer/model export interface ConnectorAccount extends LiquidityAccount { asset: LiquidityAccount['asset'] & AssetOptions + tenantId: string } export interface IncomingAccount extends ConnectorAccount { @@ -70,7 +72,8 @@ export interface RafikiServices { peers: PeerService rates: RatesService redis: Redis - streamServer: StreamServer + tenantSettingService: TenantSettingService + config: IAppConfig } export type HttpContextMixin = { @@ -106,7 +109,6 @@ export type ILPContext = { } export class Rafiki { - private streamServer: StreamServer private redis: Redis private publicServer: Koa = new Koa() @@ -118,10 +120,12 @@ export class Rafiki { this.redis = config.redis const logger = config.logger - this.streamServer = config.streamServer - const { redis, streamServer } = this + const { redis } = this // Set global context that exposes services this.publicServer.context.services = { + get config(): IAppConfig { + return config.config + }, get incomingPayments(): IncomingPaymentService { return config.incomingPayments }, @@ -134,9 +138,6 @@ export class Rafiki { get redis(): Redis { return redis }, - get streamServer(): StreamServer { - return streamServer - }, get accounting(): AccountingService { return config.accounting }, @@ -146,6 +147,9 @@ export class Rafiki { get telemetry(): TelemetryService { return config.telemetry }, + get tenantSettingService(): TenantSettingService { + return config.tenantSettingService + }, logger } @@ -205,7 +209,8 @@ export class Rafiki { response, code, scale, - telemetry + telemetry, + sourceAccount.tenantId ) return response.rawReply } diff --git a/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts b/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts index 6c7a17031e..9cd401971a 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/telemetry.ts @@ -39,7 +39,8 @@ export async function incrementAmount( response: IlpResponse, code: string, scale: number, - telemetry: TelemetryService + telemetry: TelemetryService, + tenantId?: string ): Promise { if (!unfulfillable && Number(prepareAmount) && response.fulfill) { const value = BigInt(prepareAmount) @@ -50,6 +51,7 @@ export async function incrementAmount( assetCode: code, assetScale: scale }, + tenantId, { description: 'Amount sent through the network', valueType: ValueType.DOUBLE diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts index e0159727b1..c266bd999a 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/controllers/stream-controller.test.ts @@ -17,6 +17,7 @@ import { RafikiServicesFactory } from '../../factories' import { ZeroCopyIlpPrepare } from '../../middleware/ilp-packet' +import { StreamServer } from '@interledger/stream-receiver' const sha256 = (preimage: string | Buffer): Buffer => crypto.createHash('sha256').update(preimage).digest() @@ -35,12 +36,22 @@ describe('Stream Controller', function () { test('constructs a reply for a receive account', async () => { const bob = IncomingPaymentAccountFactory.build() - const { ilpAddress, sharedSecret } = - services.streamServer.generateCredentials({ - paymentTag: 'foo' - }) + + const streamServer = new StreamServer({ + serverAddress: services.config.ilpAddress, + serverSecret: services.config.streamSecret + }) + + const { ilpAddress, sharedSecret } = streamServer.generateCredentials({ + paymentTag: 'foo' + }) + const ctx = createILPContext({ services, + state: { + streamServer, + streamDestination: streamServer.decodePaymentTag(ilpAddress) + }, accounts: { get incoming() { return alice diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts index 692dfee988..e669118cab 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/account-middleware.test.ts @@ -12,9 +12,9 @@ import { } from '../../factories' import { createAccountMiddleware } from '../../middleware/account' import { createILPContext } from '../../utils' +import { Peer } from '../../../../peer/model' describe('Account Middleware', () => { - const ADDRESS = 'test.rafiki' const incomingAccount = IncomingPeerFactory.build({ id: 'incomingPeer' }) @@ -30,7 +30,7 @@ describe('Account Middleware', () => { }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { incomingAccount }, @@ -53,7 +53,7 @@ describe('Account Middleware', () => { id: 'outgoingIncomingPayment' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -79,7 +79,7 @@ describe('Account Middleware', () => { id: 'spspFallback' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -100,13 +100,51 @@ describe('Account Middleware', () => { expect(ctx.accounts.outgoing).toEqual(outgoingAccount) }) + test('finds peer as outgoing account when no streamDestination present', async () => { + const tenantId = crypto.randomUUID() + const outgoingAccount = AccountFactory.build({ + id: 'peer' + }) + + const getByDestinationAddressSpy = jest + .spyOn(rafikiServices.peers, 'getByDestinationAddress') + .mockResolvedValueOnce(outgoingAccount as unknown as Peer) + + await rafikiServices.accounting.create(outgoingAccount) + const middleware = createAccountMiddleware() + const next = jest.fn() + const ctx = createILPContext({ + state: { + incomingAccount: { + ...incomingAccount, + tenantId + } + }, + services: rafikiServices, + request: { + prepare: new ZeroCopyIlpPrepare( + IlpPrepareFactory.build({ destination: 'test.123' }) + ), + rawPrepare: Buffer.alloc(0) // ignored + } + }) + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(ctx.accounts.incoming).toEqual({ ...incomingAccount, tenantId }) + expect(ctx.accounts.outgoing).toEqual(outgoingAccount) + expect(getByDestinationAddressSpy).toHaveBeenCalledWith( + 'test.123', + tenantId + ) + }) + test('return an error when the destination account is in an incorrect state', async () => { const outgoingAccount = IncomingPaymentAccountFactory.build({ id: 'deactivatedIncomingPayment', state: 'COMPLETED' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -127,7 +165,7 @@ describe('Account Middleware', () => { }) test('return an error when the destination account unknown', async () => { - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -154,7 +192,7 @@ describe('Account Middleware', () => { state: 'COMPLETED' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -186,7 +224,7 @@ describe('Account Middleware', () => { state: 'PENDING' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { @@ -229,7 +267,7 @@ describe('Account Middleware', () => { id: 'spspFallback' }) await rafikiServices.accounting.create(outgoingAccount) - const middleware = createAccountMiddleware(ADDRESS) + const middleware = createAccountMiddleware() const next = jest.fn() const ctx = createILPContext({ state: { diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts index 8882a19f90..35426d0baa 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/middleware/stream-address.test.ts @@ -1,36 +1,124 @@ import { createILPContext } from '../../utils' -import { ZeroCopyIlpPrepare } from '../..' +import { + AuthState, + ILPContext, + IncomingAccount, + ZeroCopyIlpPrepare +} from '../..' import { IlpPrepareFactory, RafikiServicesFactory } from '../../factories' -import { createStreamAddressMiddleware } from '../../middleware/stream-address' +import { + StreamState, + createStreamAddressMiddleware, + getIlpAddressForTenant +} from '../../middleware/stream-address' +import { StreamServer } from '@interledger/stream-receiver' +import { + TenantSetting, + TenantSettingKeys +} from '../../../../../../tenants/settings/model' describe('Stream Address Middleware', function () { const services = RafikiServicesFactory.build() - const ctx = createILPContext({ services }) + + function makeIlpContext(): ILPContext { + return createILPContext({ services }) + } + const middleware = createStreamAddressMiddleware() - test('skips non-stream packets', async () => { - const prepare = IlpPrepareFactory.build() - ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) - const next = jest.fn() + describe('createStreamAddressMiddleware', function () { + test('skips non-stream packets', async () => { + const prepare = IlpPrepareFactory.build() + const ctx = makeIlpContext() + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + const next = jest.fn() + + await expect(middleware(ctx, next)).resolves.toBeUndefined() + + expect(next).toHaveBeenCalledTimes(1) + expect(ctx.state.streamDestination).toBeUndefined() + expect(ctx.state.streamServer).toBeUndefined() + }) + + test('sets "state.streamDestination" of stream packets', async () => { + const ctx = makeIlpContext() + const streamServer = new StreamServer({ + serverAddress: ctx.services.config.ilpAddress, + serverSecret: ctx.services.config.streamSecret + }) + + const prepare = IlpPrepareFactory.build({ + destination: streamServer.generateCredentials({ + paymentTag: 'bob' + }).ilpAddress + }) + + ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) + ctx.state.incomingAccount = { + tenantId: ctx.services.config.operatorTenantId + } as IncomingAccount + const next = jest.fn() - await expect(middleware(ctx, next)).resolves.toBeUndefined() + await expect(middleware(ctx, next)).resolves.toBeUndefined() - expect(next).toHaveBeenCalledTimes(1) - expect(ctx.state.streamDestination).toBeUndefined() + expect(next).toHaveBeenCalledTimes(1) + expect(ctx.state.streamDestination).toBe('bob') + expect(ctx.state.streamServer).toBeDefined() + }) }) - test('sets "state.streamDestination" of stream packets', async () => { - const prepare = IlpPrepareFactory.build({ - destination: services.streamServer.generateCredentials({ - paymentTag: 'bob' - }).ilpAddress + describe('getIlpAddressForTenant', function () { + test('returns undefined if no state.incomingAccount set', async () => { + const ctx = makeIlpContext() + + await expect(getIlpAddressForTenant(ctx)).resolves.toBeUndefined() }) - ctx.request.prepare = new ZeroCopyIlpPrepare(prepare) - const next = jest.fn() + test('returns operator tenant ILP address if equals incomingAccount tenantId', async () => { + const ctx = makeIlpContext() + ctx.state.incomingAccount = { + tenantId: ctx.services.config.operatorTenantId + } as IncomingAccount - await expect(middleware(ctx, next)).resolves.toBeUndefined() + await expect(getIlpAddressForTenant(ctx)).resolves.toBe( + ctx.services.config.ilpAddress + ) + }) + + test('returns non-operator tenant ILP address', async () => { + const tenantId = crypto.randomUUID() + const tenantIlpAddress = 'test.rafiki' + const ctx = makeIlpContext() + + ctx.state.incomingAccount = { + tenantId: tenantId + } as IncomingAccount + + jest + .spyOn(ctx.services.tenantSettingService, 'get') + .mockResolvedValueOnce([ + { + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: tenantIlpAddress + } + ] as TenantSetting[]) + + await expect(getIlpAddressForTenant(ctx)).resolves.toBe(tenantIlpAddress) + }) + + test('returns undefined if missing ILP address tenant setting', async () => { + const tenantId = crypto.randomUUID() + const ctx = makeIlpContext() + + ctx.state.incomingAccount = { + tenantId: tenantId + } as IncomingAccount - expect(next).toHaveBeenCalledTimes(1) - expect(ctx.state['streamDestination']).toBe('bob') + jest + .spyOn(ctx.services.tenantSettingService, 'get') + .mockResolvedValueOnce([]) + + await expect(getIlpAddressForTenant(ctx)).resolves.toBeUndefined() + }) }) }) diff --git a/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts b/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts index f3ab472683..6fad868724 100644 --- a/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts +++ b/packages/backend/src/payment-method/ilp/connector/core/test/telemetry.test.ts @@ -176,7 +176,12 @@ describe('Connector Core Telemetry', () => { telemetryService ) - expect(incrementCounterSpy).toHaveBeenCalledWith(name, amount, attributes) + expect(incrementCounterSpy).toHaveBeenCalledWith( + name, + amount, + undefined, + attributes + ) }) it('incrementAmount should not increment when the prepare is unfulfillable', () => { diff --git a/packages/backend/src/payment-method/ilp/connector/index.ts b/packages/backend/src/payment-method/ilp/connector/index.ts index 8dd2f27f0d..05a3091439 100644 --- a/packages/backend/src/payment-method/ilp/connector/index.ts +++ b/packages/backend/src/payment-method/ilp/connector/index.ts @@ -1,4 +1,3 @@ -import { StreamServer } from '@interledger/stream-receiver' import { Redis } from 'ioredis' import { AccountingService } from '../../../accounting/service' @@ -28,30 +27,34 @@ import { createStreamController } from './core' import { TelemetryService } from '../../../telemetry/service' +import { TenantSettingService } from '../../../tenants/settings/service' +import { IAppConfig } from '../../../config/app' interface ServiceDependencies extends BaseService { + config: IAppConfig redis: Redis ratesService: RatesService accountingService: AccountingService walletAddressService: WalletAddressService incomingPaymentService: IncomingPaymentService peerService: PeerService - streamServer: StreamServer ilpAddress: string telemetry: TelemetryService + tenantSettingService: TenantSettingService } export async function createConnectorService({ logger, + config, redis, ratesService, accountingService, walletAddressService, incomingPaymentService, peerService, - streamServer, ilpAddress, - telemetry + telemetry, + tenantSettingService }: ServiceDependencies): Promise { return createApp( { @@ -59,20 +62,21 @@ export async function createConnectorService({ logger: logger.child({ service: 'ConnectorService' }), + config, accounting: accountingService, walletAddresses: walletAddressService, incomingPayments: incomingPaymentService, peers: peerService, redis, rates: ratesService, - streamServer, - telemetry + telemetry, + tenantSettingService }, compose([ // Incoming Rules createIncomingErrorHandlerMiddleware(ilpAddress), createStreamAddressMiddleware(), - createAccountMiddleware(ilpAddress), + createAccountMiddleware(), createIncomingMaxPacketAmountMiddleware(), createIncomingRateLimitMiddleware({}), createIncomingThroughputMiddleware(), diff --git a/packages/backend/src/payment-method/ilp/peer-http-token/service.test.ts b/packages/backend/src/payment-method/ilp/peer-http-token/service.test.ts index d9624fbb0b..1fc6c5675c 100644 --- a/packages/backend/src/payment-method/ilp/peer-http-token/service.test.ts +++ b/packages/backend/src/payment-method/ilp/peer-http-token/service.test.ts @@ -28,7 +28,7 @@ describe('HTTP Token Service', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/payment-method/ilp/peer/model.test.ts b/packages/backend/src/payment-method/ilp/peer/model.test.ts index a664509eb1..96cde21d4e 100644 --- a/packages/backend/src/payment-method/ilp/peer/model.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/model.test.ts @@ -33,7 +33,7 @@ describe('Models', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -58,7 +58,8 @@ describe('Models', (): void => { maxPacketAmount: BigInt(100), staticIlpAddress: 'test.' + uuid(), name: faker.person.fullName(), - liquidityThreshold: BigInt(100) + liquidityThreshold: BigInt(100), + tenantId: Config.operatorTenantId } const peerOrError = await peerService.create(options) if (!isPeerError(peerOrError)) { @@ -73,12 +74,11 @@ describe('Models', (): void => { `( 'creates webhook event if balance=$balance <= liquidityThreshold', async ({ balance }): Promise => { - await peer.onDebit({ balance }) + await peer.onDebit({ balance }, Config) const event = ( - await PeerEvent.query(knex).where( - 'type', - PeerEventType.LiquidityLow - ) + await PeerEvent.query(knex) + .where('type', PeerEventType.LiquidityLow) + .withGraphFetched('webhooks') )[0] expect(event).toMatchObject({ type: PeerEventType.LiquidityLow, @@ -91,12 +91,25 @@ describe('Models', (): void => { }, liquidityThreshold: peer.liquidityThreshold?.toString(), balance: balance.toString() - } + }, + tenantId: Config.operatorTenantId, + webhooks: [ + expect.objectContaining({ + recipientTenantId: peer.tenantId, + attempts: 0, + processAt: expect.any(Date) + }) + ] }) } ) test('does not create webhook event if balance > liquidityThreshold', async (): Promise => { - await peer.onDebit({ balance: BigInt(110) }) + await peer.onDebit( + { + balance: BigInt(110) + }, + Config + ) await expect( PeerEvent.query(knex).where('type', PeerEventType.LiquidityLow) ).resolves.toEqual([]) diff --git a/packages/backend/src/payment-method/ilp/peer/model.ts b/packages/backend/src/payment-method/ilp/peer/model.ts index e22619e867..813a7cdfd6 100644 --- a/packages/backend/src/payment-method/ilp/peer/model.ts +++ b/packages/backend/src/payment-method/ilp/peer/model.ts @@ -4,8 +4,10 @@ import { Asset } from '../../../asset/model' import { ConnectorAccount } from '../connector/core/rafiki' import { HttpToken } from '../peer-http-token/model' import { BaseModel } from '../../../shared/baseModel' -import { WebhookEvent } from '../../../webhook/model' +import { WebhookEvent } from '../../../webhook/event/model' import { join } from 'path' +import { IAppConfig } from '../../../config/app' +import { finalizeWebhookRecipients } from '../../../webhook/service' export class Peer extends BaseModel @@ -53,10 +55,15 @@ export class Peer public name?: string - public async onDebit({ balance }: OnDebitOptions): Promise { + public readonly tenantId!: string + + public async onDebit( + { balance }: OnDebitOptions, + config: IAppConfig + ): Promise { if (this.liquidityThreshold !== null) { if (balance <= this.liquidityThreshold) { - await PeerEvent.query().insert({ + await PeerEvent.query().insertGraph({ peerId: this.id, type: PeerEventType.LiquidityLow, data: { @@ -68,7 +75,9 @@ export class Peer }, liquidityThreshold: this.liquidityThreshold, balance - } + }, + tenantId: this.tenantId, + webhooks: finalizeWebhookRecipients([this.tenantId], config) }) } } diff --git a/packages/backend/src/payment-method/ilp/peer/service.test.ts b/packages/backend/src/payment-method/ilp/peer/service.test.ts index 9d0a7ae430..1b8d7b0533 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.test.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.test.ts @@ -24,6 +24,7 @@ describe('Peer Service', (): void => { let peerService: PeerService let accountingService: AccountingService let asset: Asset + let tenantId: string const randomPeer = (override?: Partial): CreateOptions => ({ assetId: asset.id, @@ -40,6 +41,7 @@ describe('Peer Service', (): void => { staticIlpAddress: 'test.' + uuid(), name: faker.person.fullName(), liquidityThreshold: BigInt(10000), + tenantId: Config.operatorTenantId, ...override }) @@ -48,6 +50,7 @@ describe('Peer Service', (): void => { appContainer = await createTestApp(deps) peerService = await deps.use('peerService') accountingService = await deps.use('accountingService') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { @@ -55,7 +58,7 @@ describe('Peer Service', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -93,7 +96,7 @@ describe('Peer Service', (): void => { name: options.name, liquidityThreshold: liquidityThreshold || null }) - const retrievedPeer = await peerService.get(peer.id) + const retrievedPeer = await peerService.get(peer.id, peer.tenantId) if (!retrievedPeer) throw new Error('peer not found') expect(retrievedPeer).toEqual(peer) } @@ -112,7 +115,7 @@ describe('Peer Service', (): void => { staticIlpAddress: options.staticIlpAddress, name: options.name }) - const retrievedPeer = await peerService.get(peer.id) + const retrievedPeer = await peerService.get(peer.id, peer.tenantId) if (!retrievedPeer) throw new Error('peer not found') expect(retrievedPeer).toEqual(peer) }) @@ -148,7 +151,7 @@ describe('Peer Service', (): void => { }) test('Cannot fetch a bogus peer', async (): Promise => { - await expect(peerService.get(uuid())).resolves.toBeUndefined() + await expect(peerService.get(uuid(), tenantId)).resolves.toBeUndefined() }) test('Cannot create a peer with unknown asset', async (): Promise => { @@ -161,6 +164,11 @@ describe('Peer Service', (): void => { ).resolves.toEqual(PeerError.UnknownAsset) }) + test('Cannot fetch a peer with incorrect tenantId', async (): Promise => { + const peer = await createPeer(deps) + await expect(peerService.get(peer.id, uuid())).resolves.toBeUndefined() + }) + test('Cannot create a peer with duplicate incoming tokens', async (): Promise => { const incomingToken = faker.string.sample(32) @@ -219,6 +227,13 @@ describe('Peer Service', (): void => { PeerError.DuplicatePeer ) }) + + test('Cannot create a peer with incorrect tenantId', async (): Promise => { + const options = randomPeer() + await expect( + peerService.create({ ...options, tenantId: uuid() }) + ).resolves.toEqual(PeerError.UnknownAsset) + }) }) describe('Update Peer', (): void => { @@ -237,7 +252,8 @@ describe('Peer Service', (): void => { maxPacketAmount, staticIlpAddress, name, - liquidityThreshold + liquidityThreshold, + tenantId: peer.tenantId } const peerOrError = await peerService.update(updateOptions) @@ -255,14 +271,17 @@ describe('Peer Service', (): void => { liquidityThreshold: updateOptions.liquidityThreshold || null } expect(peerOrError).toMatchObject(expectedPeer) - await expect(peerService.get(peer.id)).resolves.toEqual(peerOrError) + await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( + peerOrError + ) } ) test('Cannot update nonexistent peer', async (): Promise => { const updateOptions: UpdateOptions = { id: uuid(), - maxPacketAmount: BigInt(2) + maxPacketAmount: BigInt(2), + tenantId: Config.operatorTenantId } await expect(peerService.update(updateOptions)).resolves.toEqual( @@ -288,12 +307,15 @@ describe('Peer Service', (): void => { authTokens: [incomingToken] }, outgoing: peer.http.outgoing - } + }, + tenantId: peer.tenantId } await expect(peerService.update(updateOptions)).resolves.toEqual( PeerError.DuplicateIncomingToken ) - await expect(peerService.get(peer.id)).resolves.toEqual(peer) + await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) }) test('Returns error for duplicate incoming tokens', async (): Promise => { @@ -306,25 +328,31 @@ describe('Peer Service', (): void => { authTokens: [incomingToken, incomingToken] }, outgoing: peer.http.outgoing - } + }, + tenantId: peer.tenantId } await expect(peerService.update(updateOptions)).resolves.toEqual( PeerError.DuplicateIncomingToken ) - await expect(peerService.get(peer.id)).resolves.toEqual(peer) + await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) }) test('Returns error for invalid static ILP address', async (): Promise => { const peer = await createPeer(deps) const updateOptions: UpdateOptions = { id: peer.id, - staticIlpAddress: 'test.hello!' + staticIlpAddress: 'test.hello!', + tenantId: peer.tenantId } await expect(peerService.update(updateOptions)).resolves.toEqual( PeerError.InvalidStaticIlpAddress ) - await expect(peerService.get(peer.id)).resolves.toEqual(peer) + await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) }) test('Returns error for invalid HTTP endpoint', async (): Promise => { @@ -336,12 +364,15 @@ describe('Peer Service', (): void => { ...peer.http.outgoing, endpoint: 'http://.com' } - } + }, + tenantId: peer.tenantId } await expect(peerService.update(updateOptions)).resolves.toEqual( PeerError.InvalidHTTPEndpoint ) - await expect(peerService.get(peer.id)).resolves.toEqual(peer) + await expect(peerService.get(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) }) }) @@ -349,21 +380,27 @@ describe('Peer Service', (): void => { test('Can retrieve peer by ILP address', async (): Promise => { const peer = await createPeer(deps) await expect( - peerService.getByDestinationAddress(peer.staticIlpAddress) + peerService.getByDestinationAddress(peer.staticIlpAddress, tenantId) ).resolves.toEqual(peer) await expect( - peerService.getByDestinationAddress(peer.staticIlpAddress + '.suffix') + peerService.getByDestinationAddress( + peer.staticIlpAddress + '.suffix', + tenantId + ) ).resolves.toEqual(peer) await expect( - peerService.getByDestinationAddress(peer.staticIlpAddress + 'suffix') + peerService.getByDestinationAddress( + peer.staticIlpAddress + 'suffix', + tenantId + ) ).resolves.toBeUndefined() }) test('Returns undefined if no account exists with address', async (): Promise => { await expect( - peerService.getByDestinationAddress('test.nope') + peerService.getByDestinationAddress('test.nope', tenantId) ).resolves.toBeUndefined() }) @@ -372,7 +409,10 @@ describe('Peer Service', (): void => { staticIlpAddress: 'test.rafiki_with_wildcards' }) await expect( - peerService.getByDestinationAddress('test.rafiki-with-wildcards') + peerService.getByDestinationAddress( + 'test.rafiki-with-wildcards', + tenantId + ) ).resolves.toBeUndefined() }) @@ -391,12 +431,34 @@ describe('Peer Service', (): void => { }) await expect( - peerService.getByDestinationAddress('test.rafiki') + peerService.getByDestinationAddress('test.rafiki', tenantId, asset.id) ).resolves.toEqual(peer) await expect( - peerService.getByDestinationAddress('test.rafiki', secondAsset.id) + peerService.getByDestinationAddress( + 'test.rafiki', + tenantId, + secondAsset.id + ) ).resolves.toEqual(peerWithSecondAsset) }) + + test('returns peer with longest prefix match for ILP address', async (): Promise => { + const peer = await createPeer(deps, { + staticIlpAddress: 'test.rafiki', + assetId: asset.id + }) + + const peerWithLongerPrefixMatch = await createPeer(deps, { + staticIlpAddress: 'test.rafiki.account' + }) + + await expect( + peerService.getByDestinationAddress( + 'test.rafiki.account.12345', + peer.tenantId + ) + ).resolves.toEqual(peerWithLongerPrefixMatch) + }) }) describe('Get Peer by Incoming Token', (): void => { @@ -427,8 +489,11 @@ describe('Peer Service', (): void => { describe('Peer pagination', (): void => { getPageTests({ createModel: () => createPeer(deps, { assetId: asset.id }), - getPage: (pagination?: Pagination, sortOrder?: SortOrder) => - peerService.getPage(pagination, sortOrder) + getPage: ( + pagination?: Pagination, + sortOrder?: SortOrder, + tenantId?: string + ) => peerService.getPage(pagination, sortOrder, tenantId) }) }) @@ -436,18 +501,26 @@ describe('Peer Service', (): void => { test('Can delete peer', async (): Promise => { const peer = await createPeer(deps) - await expect(peerService.delete(peer.id)).resolves.toEqual(peer) + await expect(peerService.delete(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) }) test('Returns undefined if no peer exists by id', async (): Promise => { - await expect(peerService.delete(uuid())).resolves.toBeUndefined() + await expect( + peerService.delete(uuid(), tenantId) + ).resolves.toBeUndefined() }) test('Returns undefined for already deleted peer', async (): Promise => { const peer = await createPeer(deps) - await expect(peerService.delete(peer.id)).resolves.toEqual(peer) - await expect(peerService.delete(peer.id)).resolves.toBeUndefined() + await expect(peerService.delete(peer.id, peer.tenantId)).resolves.toEqual( + peer + ) + await expect( + peerService.delete(peer.id, peer.tenantId) + ).resolves.toBeUndefined() }) }) @@ -458,7 +531,11 @@ describe('Peer Service', (): void => { const liquidity = 100n await expect( - peerService.depositLiquidity({ peerId: peer.id, amount: liquidity }) + peerService.depositLiquidity({ + peerId: peer.id, + amount: liquidity, + tenantId: peer.tenantId + }) ).resolves.toBeUndefined() await expect(accountingService.getBalance(peer.id)).resolves.toBe( @@ -473,7 +550,8 @@ describe('Peer Service', (): void => { peerService.depositLiquidity({ peerId: peer.id, amount: 100n, - transferId: '' + transferId: '', + tenantId: peer.tenantId }) ).resolves.toBe(TransferError.InvalidId) @@ -484,7 +562,8 @@ describe('Peer Service', (): void => { await expect( peerService.depositLiquidity({ peerId: uuid(), - amount: 100n + amount: 100n, + tenantId }) ).resolves.toBe(PeerError.UnknownPeer) }) diff --git a/packages/backend/src/payment-method/ilp/peer/service.ts b/packages/backend/src/payment-method/ilp/peer/service.ts index 471e8f44d3..6f16dd1fed 100644 --- a/packages/backend/src/payment-method/ilp/peer/service.ts +++ b/packages/backend/src/payment-method/ilp/peer/service.ts @@ -22,6 +22,7 @@ import { BaseService } from '../../../shared/baseService' import { isValidHttpUrl } from '../../../shared/utils' import { v4 as uuid } from 'uuid' import { TransferError } from '../../../accounting/errors' +import PrefixMap from '../connector/ilp-routing/lib/prefix-map' export interface HttpOptions { incoming?: { @@ -44,32 +45,40 @@ export type Options = { export type CreateOptions = Options & { assetId: string + tenantId?: string } export type UpdateOptions = Partial & { id: string + tenantId?: string } interface DepositPeerLiquidityArgs { amount: bigint transferId?: string peerId: string + tenantId?: string } export interface PeerService { - get(id: string): Promise + get(id: string, tenantId?: string): Promise create(options: CreateOptions): Promise update(options: UpdateOptions): Promise getByDestinationAddress( address: string, + tenantId: string, assetId?: string ): Promise getByIncomingToken(token: string): Promise - getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise + getPage( + pagination?: Pagination, + sortOrder?: SortOrder, + tenantId?: string + ): Promise depositLiquidity( args: DepositPeerLiquidityArgs ): Promise - delete(id: string): Promise + delete(id: string, tenantId: string): Promise } interface ServiceDependencies extends BaseService { @@ -97,24 +106,29 @@ export async function createPeerService({ httpTokenService } return { - get: (id) => getPeer(deps, id), + get: (id, tenantId) => getPeer(deps, id, tenantId), create: (options) => createPeer(deps, options), update: (options) => updatePeer(deps, options), - getByDestinationAddress: (destinationAddress, assetId) => - getPeerByDestinationAddress(deps, destinationAddress, assetId), + getByDestinationAddress: (destinationAddress, tenantId, assetId) => + getPeerByDestinationAddress(deps, destinationAddress, tenantId, assetId), getByIncomingToken: (token) => getPeerByIncomingToken(deps, token), - getPage: (pagination?, sortOrder?) => - getPeersPage(deps, pagination, sortOrder), + getPage: (pagination?, sortOrder?, tenantId?) => + getPeersPage(deps, pagination, sortOrder, tenantId), depositLiquidity: (args) => depositLiquidityById(deps, args), - delete: (id) => deletePeer(deps, id) + delete: (id, tenantId) => deletePeer(deps, id, tenantId) } } async function getPeer( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { - const peer = await Peer.query(deps.knex).findById(id) + let query = Peer.query(deps.knex) + if (tenantId) { + query = query.where('tenantId', tenantId) + } + const peer = await query.findOne({ id }) if (peer) { const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset @@ -134,6 +148,11 @@ async function createPeer( return PeerError.InvalidHTTPEndpoint } + const asset = await deps.assetService.get(options.assetId, options.tenantId) + if (!asset) { + return PeerError.UnknownAsset + } + try { return await Peer.transaction(deps.knex, async (trx) => { const peer = await Peer.query(trx).insertAndFetch({ @@ -142,10 +161,10 @@ async function createPeer( maxPacketAmount: options.maxPacketAmount, staticIlpAddress: options.staticIlpAddress, name: options.name, - liquidityThreshold: options.liquidityThreshold + liquidityThreshold: options.liquidityThreshold, + tenantId: asset.tenantId }) - const asset = await deps.assetService.get(peer.assetId) - if (asset) peer.asset = asset + peer.asset = asset if (options.http?.incoming) { const err = await addIncomingHttpTokens({ @@ -259,9 +278,9 @@ async function depositLiquidityById( deps: ServiceDependencies, args: DepositPeerLiquidityArgs ): Promise { - const { peerId, amount, transferId } = args + const { peerId, amount, transferId, tenantId } = args - const peer = await getPeer(deps, peerId) + const peer = await getPeer(deps, peerId, tenantId) if (!peer) { return PeerError.UnknownPeer } @@ -316,6 +335,7 @@ async function addIncomingHttpTokens({ async function getPeerByDestinationAddress( deps: ServiceDependencies, destinationAddress: string, + tenantId?: string, assetId?: string ): Promise { // This query does the equivalent of the following regex @@ -350,7 +370,13 @@ async function getPeerByDestinationAddress( peerQuery.andWhere('assetId', assetId) } - const peer = await peerQuery.first() + if (tenantId) { + peerQuery.andWhere('tenantId', tenantId) + } + + const peers = await peerQuery + const peer = getByLongestPrefixMatch(peers, destinationAddress) + if (peer) { const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset @@ -358,6 +384,18 @@ async function getPeerByDestinationAddress( return peer || undefined } +function getByLongestPrefixMatch( + peers: Peer[], + destinationAddress: string +): Peer | undefined { + const map = new PrefixMap() + for (const peer of peers) { + map.insert(peer.staticIlpAddress, peer) + } + + return map.resolve(destinationAddress) +} + async function getPeerByIncomingToken( deps: ServiceDependencies, token: string @@ -383,9 +421,15 @@ async function getPeerByIncomingToken( async function getPeersPage( deps: ServiceDependencies, pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise { - const peers = await Peer.query(deps.knex).getPage(pagination, sortOrder) + let query = Peer.query(deps.knex) + if (tenantId) { + query = query.where('tenantId', tenantId) + } + + const peers = await query.getPage(pagination, sortOrder) for (const peer of peers) { const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset @@ -395,9 +439,15 @@ async function getPeersPage( async function deletePeer( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { - const peer = await Peer.query(deps.knex).deleteById(id).returning('*').first() + const peer = await Peer.query(deps.knex) + .delete() + .where('id', id) + .andWhere('tenantId', tenantId) + .returning('*') + .first() if (peer) { const asset = await deps.assetService.get(peer.assetId) if (asset) peer.asset = asset diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 75fddbeccb..0aad524325 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -1,6 +1,7 @@ import { IlpPaymentService, calculateMinSendAmount, + resolveIlpDestination, retryableIlpErrors } from './service' import { initIocContainer } from '../../' @@ -31,6 +32,11 @@ import { truncateTables } from '../../tests/tableManager' import { createOutgoingPaymentWithReceiver } from '../../tests/outgoingPayment' import { v4 as uuid } from 'uuid' import { IlpQuoteDetails } from './quote-details/model' +import { CreateOptions } from '../../tenants/settings/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../tests/tenantSettings' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -40,8 +46,9 @@ describe('IlpPaymentService', (): void => { let ilpPaymentService: IlpPaymentService let accountingService: AccountingService let config: IAppConfig + let tenantId: string - const exchangeRatesUrl = 'https://example-rates.com' + let tenantExchangeRatesUrl: string const assetMap: Record = {} const walletAddressMap: Record = {} @@ -49,7 +56,6 @@ describe('IlpPaymentService', (): void => { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime: 0 }) appContainer = await createTestApp(deps) @@ -60,27 +66,42 @@ describe('IlpPaymentService', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId assetMap['USD'] = await createAsset(deps, { - code: 'USD', - scale: 2 + assetOptions: { + code: 'USD', + scale: 2 + } }) assetMap['EUR'] = await createAsset(deps, { - code: 'EUR', - scale: 2 + assetOptions: { + code: 'EUR', + scale: 2 + } }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['EUR'].id }) + + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) jest.restoreAllMocks() nock.cleanAll() @@ -95,7 +116,7 @@ describe('IlpPaymentService', (): void => { describe('getQuote', (): void => { test('calls rates service with correct base asset', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -113,12 +134,12 @@ describe('IlpPaymentService', (): void => { await ilpPaymentService.getQuote(options) - expect(ratesServiceSpy).toHaveBeenCalledWith('USD') + expect(ratesServiceSpy).toHaveBeenCalledWith('USD', tenantId) ratesScope.done() }) test('inserts ilpQuoteDetails', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const quoteId = uuid() const options: StartQuoteOptions = { quoteId, @@ -180,7 +201,7 @@ describe('IlpPaymentService', (): void => { }) test('creates a quote with large exchange rate amounts', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const quoteId = uuid() const options: StartQuoteOptions = { quoteId, @@ -298,7 +319,7 @@ describe('IlpPaymentService', (): void => { }) test('returns all fields correctly', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -330,7 +351,7 @@ describe('IlpPaymentService', (): void => { }) test('uses receiver.incomingAmount if receiveAmount is not provided', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const incomingAmount = { assetCode: 'USD', @@ -342,7 +363,8 @@ describe('IlpPaymentService', (): void => { quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -369,7 +391,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage: 101 }, async () => { - mockRatesApi(exchangeRatesUrl, () => ({})) + mockRatesApi(tenantExchangeRatesUrl, () => ({})) expect.assertions(4) try { @@ -397,7 +419,7 @@ describe('IlpPaymentService', (): void => { )()) test('throws if quote returns invalid maxSourceAmount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -427,7 +449,7 @@ describe('IlpPaymentService', (): void => { }) test('throws if quote returns invalid minDeliveryAmount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -437,7 +459,8 @@ describe('IlpPaymentService', (): void => { assetCode: 'USD', assetScale: 2, value: 100n - } + }, + tenantId: Config.operatorTenantId }) } @@ -473,7 +496,7 @@ describe('IlpPaymentService', (): void => { }) test('throws if quote returns with a non-positive estimated delivery amount', async (): Promise => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) const options: StartQuoteOptions = { quoteId: uuid(), @@ -510,7 +533,7 @@ describe('IlpPaymentService', (): void => { const rate = 0.1 test('if debitAmount is 0', async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ EUR: rate })) const options: StartQuoteOptions = { @@ -542,7 +565,7 @@ describe('IlpPaymentService', (): void => { }) test('if estimatedReceiveAmount < 1', async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ EUR: rate })) const options: StartQuoteOptions = { @@ -576,6 +599,42 @@ describe('IlpPaymentService', (): void => { }) }) + test('throws if invalid ILP destination', async (): Promise => { + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({})) + + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const options: StartQuoteOptions = { + quoteId: uuid(), + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [] + }) + } + + expect.assertions(4) + try { + await ilpPaymentService.getQuote(options) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'No ILP payment method found in receiver' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + + ratesScope.done() + }) + describe('successfully gets ilp quote', (): void => { describe('with incomingAmount', () => { test.each` @@ -598,7 +657,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage }, async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) @@ -658,7 +717,7 @@ describe('IlpPaymentService', (): void => { () => config, { slippage }, async () => { - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) @@ -745,6 +804,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -775,6 +835,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, exchangeRate: 1, debitAmount: { value: 100n, @@ -825,6 +886,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -865,6 +927,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -898,6 +961,43 @@ describe('IlpPaymentService', (): void => { }) }) + test('throws if invalid ILP destination', async (): Promise => { + const { receiver, outgoingPayment } = + await createOutgoingPaymentWithReceiver(deps, { + sendingWalletAddress: walletAddressMap['USD'], + receivingWalletAddress: walletAddressMap['USD'], + method: 'ilp', + quoteOptions: { + tenantId, + debitAmount: { + value: 100n, + assetScale: walletAddressMap['USD'].asset.scale, + assetCode: walletAddressMap['USD'].asset.code + } + }, + receiverPaymentMethods: [] + }) + + expect.assertions(4) + try { + await ilpPaymentService.pay({ + receiver, + outgoingPayment, + finalDebitAmount: 50n, + finalReceiveAmount: 0n + }) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Could not start ILP streaming' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'Invalid finalReceiveAmount' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + test('throws retryable ILP error', async (): Promise => { const { receiver, outgoingPayment } = await createOutgoingPaymentWithReceiver(deps, { @@ -905,6 +1005,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -942,6 +1043,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -982,12 +1084,12 @@ describe('IlpPaymentService', (): void => { expect( calculateMinSendAmount({ highEstimatedExchangeRate: Pay.Ratio.from(0.05) - } as Pay.Quote) + } as unknown as Pay.Quote) ).toBe(20n) expect( calculateMinSendAmount({ highEstimatedExchangeRate: Pay.Ratio.from(0.01) - } as Pay.Quote) + } as unknown as Pay.Quote) ).toBe(100n) }) @@ -995,13 +1097,77 @@ describe('IlpPaymentService', (): void => { expect( calculateMinSendAmount({ highEstimatedExchangeRate: Pay.Ratio.from(1) - } as Pay.Quote) + } as unknown as Pay.Quote) ).toBe(2n) expect( calculateMinSendAmount({ highEstimatedExchangeRate: Pay.Ratio.from(20) - } as Pay.Quote) + } as unknown as Pay.Quote) ).toBe(2n) }) }) + + describe('resolveIlpDestination', (): void => { + test('throws if missing payment methods on receiver', async (): Promise => { + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const receiver = await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [] + }) + + expect.assertions(4) + try { + resolveIlpDestination(receiver) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'No ILP payment method found in receiver' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + + test('throws if invalid ILP address for receiver', async (): Promise => { + const incomingAmount = { + assetCode: 'USD', + assetScale: 2, + value: 100n + } + + const receiver = await createReceiver(deps, walletAddressMap['USD'], { + incomingAmount, + tenantId: Config.operatorTenantId, + paymentMethods: [ + { + type: 'ilp', + ilpAddress: '', + sharedSecret: '' + } + ] + }) + + expect.assertions(4) + try { + resolveIlpDestination(receiver) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Invalid ILP payment method on receiver' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'Invalid ILP address for ILP payment method' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + } + }) + }) }) diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index 60c7e9dbb5..633f01ea71 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -17,6 +17,9 @@ import { import { TelemetryService } from '../../telemetry/service' import { IlpQuoteDetails } from './quote-details/model' import { Transaction } from 'objection' +import { Receiver } from '../../open_payments/receiver/model' +import { IlpAddress, isValidIlpAddress } from 'ilp-packet' +import base64url from 'base64url' const MAX_INT64 = BigInt('9223372036854775807') @@ -64,7 +67,10 @@ async function getQuote( ) let rates try { - rates = await deps.ratesService.rates(options.walletAddress.asset.code) + rates = await deps.ratesService.rates( + options.walletAddress.asset.code, + options.walletAddress.tenantId + ) } catch (_err) { throw new PaymentMethodHandlerError('Received error during ILP quoting', { description: 'Could not get rates from service', @@ -86,7 +92,7 @@ async function getQuote( unfulfillable: true }) stopTimerPlugin() - const destination = options.receiver.toResolvedPayment() + const destination = resolveIlpDestination(options.receiver) try { const stopTimerConnect = deps.telemetry.startTimer( @@ -331,7 +337,7 @@ async function pay( sourceAccount: outgoingPayment }) - const destination = receiver.toResolvedPayment() + const destination = resolveIlpDestination(receiver) try { const receipt = await Pay.pay({ plugin, destination, quote }) @@ -394,6 +400,39 @@ export function calculateMinSendAmount(quote: Pay.Quote): bigint { return BigInt(Math.max(minSendAmount, 2)) // because of rounding in ILP pay, you must always send at least 2 units of an asset } +export function resolveIlpDestination(receiver: Receiver): Pay.ResolvedPayment { + const ilpPaymentMethod = receiver.paymentMethods.find( + (method) => method.type === 'ilp' + ) + + if (!ilpPaymentMethod) { + throw new PaymentMethodHandlerError( + 'Invalid ILP payment method on receiver', + { + description: 'No ILP payment method found in receiver', + retryable: false + } + ) + } + + if (!isValidIlpAddress(ilpPaymentMethod.ilpAddress)) { + throw new PaymentMethodHandlerError( + 'Invalid ILP payment method on receiver', + { + description: 'Invalid ILP address for ILP payment method', + retryable: false + } + ) + } + + return { + destinationAddress: ilpPaymentMethod.ilpAddress as IlpAddress, + destinationAsset: receiver.asset, + sharedSecret: base64url.toBuffer(ilpPaymentMethod.sharedSecret), + requestCounter: Pay.Counter.from(0) as Pay.Counter + } +} + export function canRetryError(err: Error | Pay.PaymentError): boolean { return err instanceof Error || !!retryableIlpErrors[err] } diff --git a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts index 9a4705f9c6..8d24d79e25 100644 --- a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts @@ -37,6 +37,7 @@ describe('SPSP Middleware', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ctx = setup({ @@ -48,7 +49,7 @@ describe('SPSP Middleware', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { diff --git a/packages/backend/src/payment-method/ilp/spsp/routes.test.ts b/packages/backend/src/payment-method/ilp/spsp/routes.test.ts index b02c18274c..fdef34c42e 100644 --- a/packages/backend/src/payment-method/ilp/spsp/routes.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/routes.test.ts @@ -41,7 +41,7 @@ describe('SPSP Routes', (): void => { }) afterAll(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) await appContainer.shutdown() }) diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts index 3c2881bd70..ef716de5c8 100644 --- a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts +++ b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts @@ -1,40 +1,25 @@ -import { Knex } from 'knex' import { createTestApp, TestContainer } from '../../../tests/app' import { StreamCredentialsService } from './service' -import { IncomingPayment } from '../../../open_payments/payment/incoming/model' import { Config } from '../../../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../../..' import { AppServices } from '../../../app' -import { createIncomingPayment } from '../../../tests/incomingPayment' -import { createWalletAddress } from '../../../tests/walletAddress' import { truncateTables } from '../../../tests/tableManager' import assert from 'assert' -import { IncomingPaymentState } from '../../../graphql/generated/graphql' describe('Stream Credentials Service', (): void => { let deps: IocContract let appContainer: TestContainer let streamCredentialsService: StreamCredentialsService - let knex: Knex - let incomingPayment: IncomingPayment beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) streamCredentialsService = await deps.use('streamCredentialsService') - knex = appContainer.knex - }) - - beforeEach(async (): Promise => { - const { id: walletAddressId } = await createWalletAddress(deps) - incomingPayment = await createIncomingPayment(deps, { - walletAddressId - }) }) afterEach(async (): Promise => { - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -42,8 +27,15 @@ describe('Stream Credentials Service', (): void => { }) describe('get', (): void => { - test('returns stream credentials for incoming payment', (): void => { - const credentials = streamCredentialsService.get(incomingPayment) + test('generates stream credentials', (): void => { + const credentials = streamCredentialsService.get({ + ilpAddress: 'test.rafiki', + paymentTag: crypto.randomUUID(), + asset: { + code: 'USD', + scale: 2 + } + }) assert.ok(credentials) expect(credentials).toMatchObject({ ilpAddress: expect.stringMatching(/^test\.rafiki\.[a-zA-Z0-9_-]{95}$/), @@ -51,25 +43,24 @@ describe('Stream Credentials Service', (): void => { }) }) - test.each` - state - ${IncomingPaymentState.Completed} - ${IncomingPaymentState.Expired} - `( - `returns stream credentials for $state incoming payment`, - async ({ state }): Promise => { - await incomingPayment.$query(knex).patch({ - state, - expiresAt: - state === IncomingPaymentState.Expired ? new Date() : undefined - }) - expect(streamCredentialsService.get(incomingPayment)).toMatchObject({ - ilpAddress: expect.stringMatching( - /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ - ), - sharedSecret: expect.any(Buffer) - }) + test('generates different stream credentials for a different ilpAddress', (): void => { + const args = { + paymentTag: crypto.randomUUID(), + asset: { + code: 'USD', + scale: 2 + } } - ) + + expect( + streamCredentialsService.get({ ...args, ilpAddress: 'test.rafiki' }) + ?.ilpAddress + ).not.toEqual( + streamCredentialsService.get({ + ...args, + ilpAddress: 'test.rafiki.sub-account' + })?.ilpAddress + ) + }) }) }) diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.ts index 25ba8261cd..04903136d8 100644 --- a/packages/backend/src/payment-method/ilp/stream-credentials/service.ts +++ b/packages/backend/src/payment-method/ilp/stream-credentials/service.ts @@ -1,17 +1,25 @@ import { StreamServer } from '@interledger/stream-receiver' import { BaseService } from '../../../shared/baseService' -import { IncomingPayment } from '../../../open_payments/payment/incoming/model' import { StreamCredentials as IlpStreamCredentials } from '@interledger/stream-receiver' +import { IAppConfig } from '../../../config/app' export { IlpStreamCredentials } +interface GetStreamCredentialsArgs { + ilpAddress: string + paymentTag: string + asset: { + scale: number + code: string + } +} + export interface StreamCredentialsService { - get(payment: IncomingPayment): IlpStreamCredentials | undefined + get(args: GetStreamCredentialsArgs): IlpStreamCredentials | undefined } export interface ServiceDependencies extends BaseService { - openPaymentsUrl: string - streamServer: StreamServer + config: IAppConfig } export async function createStreamCredentialsService( @@ -25,20 +33,22 @@ export async function createStreamCredentialsService( logger: log } return { - get: (payment) => getStreamCredentials(deps, payment) + get: (args) => getStreamCredentials(deps, args) } } function getStreamCredentials( deps: ServiceDependencies, - payment: IncomingPayment + args: GetStreamCredentialsArgs ): IlpStreamCredentials | undefined { - const credentials = deps.streamServer.generateCredentials({ - paymentTag: payment.id, - asset: { - code: payment.asset.code, - scale: payment.asset.scale - } + const streamServer = new StreamServer({ + serverSecret: deps.config.streamSecret, + serverAddress: args.ilpAddress + }) + + const credentials = streamServer.generateCredentials({ + paymentTag: args.paymentTag, + asset: args.asset }) return credentials } diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts index 9a8eb47cdd..6631b1ca4b 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -24,6 +24,11 @@ import { IncomingPaymentService } from '../../open_payments/payment/incoming/ser import { errorToMessage, TransferError } from '../../accounting/errors' import { PaymentMethodHandlerError } from '../handler/errors' import { ConvertError } from '../../rates/service' +import { + createTenantSettings, + exchangeRatesSetting +} from '../../tests/tenantSettings' +import { CreateOptions } from '../../tenants/settings/service' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -33,8 +38,9 @@ describe('LocalPaymentService', (): void => { let localPaymentService: LocalPaymentService let accountingService: AccountingService let incomingPaymentService: IncomingPaymentService + let tenantId: string - const exchangeRatesUrl = 'https://example-rates.com' + let tenantExchangeRatesUrl: string const assetMap: Record = {} const walletAddressMap: Record = {} @@ -42,7 +48,6 @@ describe('LocalPaymentService', (): void => { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime: 0 }) appContainer = await createTestApp(deps) @@ -53,36 +58,54 @@ describe('LocalPaymentService', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId assetMap['USD'] = await createAsset(deps, { - code: 'USD', - scale: 2 + assetOptions: { + code: 'USD', + scale: 2 + } }) assetMap['USD_9'] = await createAsset(deps, { - code: 'USD_9', - scale: 9 + assetOptions: { + code: 'USD_9', + scale: 9 + } }) assetMap['EUR'] = await createAsset(deps, { - code: 'EUR', - scale: 2 + assetOptions: { + code: 'EUR', + scale: 2 + } }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['USD_9'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD_9'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['EUR'].id }) + + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) jest.restoreAllMocks() nock.cleanAll() @@ -260,7 +283,8 @@ describe('LocalPaymentService', (): void => { const options: StartQuoteOptions = { walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -297,7 +321,7 @@ describe('LocalPaymentService', (): void => { let ratesScope if (incomingAssetCode !== debitAssetCode) { - ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) } @@ -353,7 +377,7 @@ describe('LocalPaymentService', (): void => { let ratesScope if (debitAssetCode !== incomingAssetCode) { - ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) } @@ -398,7 +422,7 @@ describe('LocalPaymentService', (): void => { const incomingAssetCode = 'USD' const expectedMinSendAmount = 100n - const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({ + const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({ [incomingAssetCode]: exchangeRate })) @@ -454,6 +478,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -484,6 +509,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -521,6 +547,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -602,6 +629,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -641,6 +669,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -680,6 +709,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -720,6 +750,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, diff --git a/packages/backend/src/payment-method/local/service.ts b/packages/backend/src/payment-method/local/service.ts index 37661d7066..29daa328ae 100644 --- a/packages/backend/src/payment-method/local/service.ts +++ b/packages/backend/src/payment-method/local/service.ts @@ -66,14 +66,15 @@ async function getQuote( let exchangeRate: number const convert = async ( - opts: RateConvertSourceOpts | RateConvertDestinationOpts + opts: RateConvertSourceOpts | RateConvertDestinationOpts, + tenantId?: string ): Promise => { let convertResults: ConvertResults | ConvertError try { convertResults = 'sourceAmount' in opts - ? await deps.ratesService.convertSource(opts) - : await deps.ratesService.convertDestination(opts) + ? await deps.ratesService.convertSource(opts, tenantId) + : await deps.ratesService.convertDestination(opts, tenantId) } catch (err) { deps.logger.error( { opts, err }, @@ -92,17 +93,20 @@ async function getQuote( if (debitAmount) { debitAmountValue = debitAmount.value - const convertResults = await convert({ - sourceAmount: debitAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + sourceAmount: debitAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiver.assetCode, + scale: receiver.assetScale + } }, - destinationAsset: { - code: receiver.assetCode, - scale: receiver.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', @@ -116,17 +120,20 @@ async function getQuote( exchangeRate = convertResults.scaledExchangeRate } else if (receiveAmount) { receiveAmountValue = receiveAmount.value - const convertResults = await convert({ - destinationAmount: receiveAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + destinationAmount: receiveAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiveAmount.assetCode, + scale: receiveAmount.assetScale + } }, - destinationAsset: { - code: receiveAmount.assetCode, - scale: receiveAmount.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', @@ -140,17 +147,20 @@ async function getQuote( exchangeRate = convertResults.scaledExchangeRate } else if (receiver.incomingAmount) { receiveAmountValue = receiver.incomingAmount.value - const convertResults = await convert({ - destinationAmount: receiveAmountValue, - sourceAsset: { - code: walletAddress.asset.code, - scale: walletAddress.asset.scale + const convertResults = await convert( + { + destinationAmount: receiveAmountValue, + sourceAsset: { + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + destinationAsset: { + code: receiver.incomingAmount.assetCode, + scale: receiver.incomingAmount.assetScale + } }, - destinationAsset: { - code: receiver.incomingAmount.assetCode, - scale: receiver.incomingAmount.assetScale - } - }) + options.walletAddress.tenantId + ) if (isConvertError(convertResults)) { throw new PaymentMethodHandlerError( 'Received error during local quoting', diff --git a/packages/backend/src/payment-method/provider/service.test.ts b/packages/backend/src/payment-method/provider/service.test.ts new file mode 100644 index 0000000000..76e5f05202 --- /dev/null +++ b/packages/backend/src/payment-method/provider/service.test.ts @@ -0,0 +1,179 @@ +import { PaymentMethodProviderService } from './service' +import { initIocContainer } from '../../' +import { createTestApp, TestContainer } from '../../tests/app' +import { Config } from '../../config/app' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createAsset } from '../../tests/asset' +import { createWalletAddress } from '../../tests/walletAddress' + +import { truncateTables } from '../../tests/tableManager' +import { createIncomingPayment } from '../../tests/incomingPayment' +import { StreamCredentialsService } from '../ilp/stream-credentials/service' +import { IlpAddress } from 'ilp-packet' +import base64url from 'base64url' +import { TenantSettingService } from '../../tenants/settings/service' +import { TenantSetting, TenantSettingKeys } from '../../tenants/settings/model' +import { IncomingPayment } from '../../open_payments/payment/incoming/model' + +describe('PaymentMethodProviderService', (): void => { + let deps: IocContract + let appContainer: TestContainer + let paymentMethodProviderService: PaymentMethodProviderService + let streamCredentialsService: StreamCredentialsService + let tenantSettingService: TenantSettingService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) + streamCredentialsService = await deps.use('streamCredentialsService') + tenantSettingService = await deps.use('tenantSettingService') + }) + + afterEach(async (): Promise => { + jest.restoreAllMocks() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('getPaymentMethods', (): void => { + const tenantId = Config.operatorTenantId + let incomingPayment: IncomingPayment + + beforeEach(async (): Promise => { + const asset = await createAsset(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: asset.id + }) + incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId + }) + }) + test('returns payment methods with ILP payment method for operator tenant', async (): Promise => { + const tenantSettingsServiceGetSpy = jest.spyOn( + tenantSettingService, + 'get' + ) + + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(({ ilpAddress }) => ({ + ilpAddress: ilpAddress as IlpAddress, + sharedSecret: Buffer.from('secret') + })) + + await expect( + paymentMethodProviderService.getPaymentMethods(incomingPayment) + ).resolves.toEqual([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: base64url(Buffer.from('secret')) + } + ]) + + expect(tenantSettingsServiceGetSpy).not.toHaveBeenCalled() + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + + test('returns payment methods with ILP payment method for non-operator tenant', async (): Promise => { + const tenantId = crypto.randomUUID() + const tenantSettingsServiceGetSpy = jest + .spyOn(tenantSettingService, 'get') + .mockResolvedValueOnce([ + { + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.rafiki' + } + ] as TenantSetting[]) + + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(({ ilpAddress }) => ({ + ilpAddress: ilpAddress as IlpAddress, + sharedSecret: Buffer.from('secret') + })) + + await expect( + paymentMethodProviderService.getPaymentMethods({ + ...incomingPayment, + tenantId + } as IncomingPayment) + ).resolves.toEqual([ + { + type: 'ilp', + ilpAddress: 'test.rafiki', + sharedSecret: base64url(Buffer.from('secret')) + } + ]) + + expect(tenantSettingsServiceGetSpy).toHaveBeenCalledWith({ + tenantId: tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + + test('does not return ILP payment method when missing ILP address in tenant settings for non-operator tenant', async (): Promise => { + const tenantId = crypto.randomUUID() + const tenantSettingsServiceGetSpy = jest + .spyOn(tenantSettingService, 'get') + .mockResolvedValueOnce([]) + + await expect( + paymentMethodProviderService.getPaymentMethods({ + ...incomingPayment, + tenantId + } as IncomingPayment) + ).resolves.toEqual([]) + + expect(tenantSettingsServiceGetSpy).toHaveBeenCalledWith({ + tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + }) + + test('does not return ILP payment method when failed to generate stream credentials', async (): Promise => { + const streamCredentialsServiceGetSpy = jest + .spyOn(streamCredentialsService, 'get') + .mockImplementationOnce(() => undefined) + + await expect( + paymentMethodProviderService.getPaymentMethods(incomingPayment) + ).resolves.toEqual([]) + + expect(streamCredentialsServiceGetSpy).toHaveBeenCalledWith({ + paymentTag: incomingPayment.id, + ilpAddress: 'test.rafiki', + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + }) + }) +}) diff --git a/packages/backend/src/payment-method/provider/service.ts b/packages/backend/src/payment-method/provider/service.ts new file mode 100644 index 0000000000..59cd3ab7b8 --- /dev/null +++ b/packages/backend/src/payment-method/provider/service.ts @@ -0,0 +1,119 @@ +import { BaseService } from '../../shared/baseService' +import { IncomingPayment } from '../../open_payments/payment/incoming/model' +import { StreamCredentialsService } from '../ilp/stream-credentials/service' +import { TenantSettingService } from '../../tenants/settings/service' +import { TenantSettingKeys } from '../../tenants/settings/model' +import base64url from 'base64url' +import { IAppConfig } from '../../config/app' + +interface BasePaymentMethod { + type: 'ilp' +} + +interface IlpPaymentMethod extends BasePaymentMethod { + type: 'ilp' + ilpAddress: string + sharedSecret: string +} + +export type OpenPaymentsPaymentMethod = IlpPaymentMethod + +export interface PaymentMethodProviderService { + getPaymentMethods( + incomingPayment: IncomingPayment + ): Promise +} + +interface ServiceDependencies extends BaseService { + streamCredentialsService: StreamCredentialsService + tenantSettingsService: TenantSettingService + config: IAppConfig +} + +export async function createPaymentMethodProviderService({ + logger, + knex, + config, + streamCredentialsService, + tenantSettingsService +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'PaymentMethodProvider' + }) + const deps: ServiceDependencies = { + logger: log, + knex, + config, + streamCredentialsService, + tenantSettingsService + } + + return { + getPaymentMethods: (incomingPayment) => + getPaymentMethods(deps, incomingPayment) + } +} + +async function getPaymentMethods( + deps: ServiceDependencies, + incomingPayment: IncomingPayment +): Promise { + const ilpPaymentMethod = await generateIlpPaymentMethod(deps, incomingPayment) + + return ilpPaymentMethod ? [ilpPaymentMethod] : [] +} + +async function generateIlpPaymentMethod( + deps: ServiceDependencies, + incomingPayment: IncomingPayment +): Promise { + let tenantIlpAddress + + if (deps.config.operatorTenantId === incomingPayment.tenantId) { + tenantIlpAddress = deps.config.ilpAddress + } else { + const tenantSettings = await deps.tenantSettingsService.get({ + tenantId: incomingPayment.tenantId, + key: TenantSettingKeys.ILP_ADDRESS.name + }) + + if (tenantSettings.length === 0) { + deps.logger.error( + { + tenantId: incomingPayment.tenantId, + incomingPaymentId: incomingPayment.id + }, + 'Could not get tenant settings for tenant when generating ILP payment method' + ) + return + } + + tenantIlpAddress = tenantSettings[0].value + } + + const ilpStreamCredentials = deps.streamCredentialsService.get({ + paymentTag: incomingPayment.id, + ilpAddress: tenantIlpAddress, + asset: { + code: incomingPayment.asset.code, + scale: incomingPayment.asset.scale + } + }) + + if (!ilpStreamCredentials) { + deps.logger.error( + { + tenantId: incomingPayment.tenantId, + incomingPaymentId: incomingPayment.id + }, + 'Could not get generate ILP STREAM credentials when generating ILP payment method' + ) + return + } + + return { + type: 'ilp', + ilpAddress: ilpStreamCredentials.ilpAddress, + sharedSecret: base64url(ilpStreamCredentials.sharedSecret) + } +} diff --git a/packages/backend/src/rates/service.test.ts b/packages/backend/src/rates/service.test.ts index 5107a30cb9..42880c2ca9 100644 --- a/packages/backend/src/rates/service.test.ts +++ b/packages/backend/src/rates/service.test.ts @@ -7,16 +7,23 @@ import { AppServices } from '../app' import { CacheDataStore } from '../middleware/cache/data-stores' import { mockRatesApi } from '../tests/rates' import { AxiosInstance } from 'axios' +import { + createTenantSettings, + exchangeRatesSetting +} from '../tests/tenantSettings' +import { CreateOptions } from '../tenants/settings/service' const nock = (global as unknown as { nock: typeof import('nock') }).nock describe('Rates service', function () { + let tenantId: string let deps: IocContract let appContainer: TestContainer let service: RatesService let apiRequestCount = 0 const exchangeRatesLifetime = 100 - const exchangeRatesUrl = 'http://example-rates.com' + + let tenantExchangeRatesUrl: string const exampleRates = { USD: { @@ -36,9 +43,18 @@ describe('Rates service', function () { beforeAll(async (): Promise => { deps = initIocContainer({ ...Config, - exchangeRatesUrl, exchangeRatesLifetime }) + tenantId = Config.operatorTenantId + const createOptions: CreateOptions = { + tenantId: Config.operatorTenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + tenantExchangeRatesUrl = (await tenantSetting).value + + expect(tenantExchangeRatesUrl).not.toBe(undefined) appContainer = await createTestApp(deps) service = await deps.use('ratesService') @@ -46,7 +62,7 @@ describe('Rates service', function () { beforeEach(async (): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;((service as any).cachedRates as CacheDataStore).deleteAll() + ;((service as any).cache as CacheDataStore).deleteAll() apiRequestCount = 0 }) @@ -61,7 +77,7 @@ describe('Rates service', function () { describe('convertSource', () => { beforeAll(() => { - mockRatesApi(exchangeRatesUrl, (base) => { + mockRatesApi(tenantExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -76,11 +92,14 @@ describe('Rates service', function () { it('returns the source amount when assets are alike', async () => { await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 9 }, - destinationAsset: { code: 'USD', scale: 9 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 9 }, + destinationAsset: { code: 'USD', scale: 9 } + }, + tenantId + ) ).resolves.toEqual({ amount: 1234n, scaledExchangeRate: 1 @@ -90,21 +109,27 @@ describe('Rates service', function () { it('scales the source amount when currencies are alike but scales are different', async () => { await expect( - service.convertSource({ - sourceAmount: 123n, - sourceAsset: { code: 'USD', scale: 9 }, - destinationAsset: { code: 'USD', scale: 12 } - }) + service.convertSource( + { + sourceAmount: 123n, + sourceAsset: { code: 'USD', scale: 9 }, + destinationAsset: { code: 'USD', scale: 12 } + }, + tenantId + ) ).resolves.toEqual({ amount: 123_000n, scaledExchangeRate: 1000 }) await expect( - service.convertSource({ - sourceAmount: 123456n, - sourceAsset: { code: 'USD', scale: 12 }, - destinationAsset: { code: 'USD', scale: 9 } - }) + service.convertSource( + { + sourceAmount: 123456n, + sourceAsset: { code: 'USD', scale: 12 }, + destinationAsset: { code: 'USD', scale: 9 } + }, + tenantId + ) ).resolves.toEqual({ amount: 123n, scaledExchangeRate: 0.001 @@ -115,21 +140,27 @@ describe('Rates service', function () { it('returns the converted amount when assets are different', async () => { const sourceAmount = 500 await expect( - service.convertSource({ - sourceAmount: BigInt(sourceAmount), - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'EUR', scale: 2 } - }) + service.convertSource( + { + sourceAmount: BigInt(sourceAmount), + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'EUR', scale: 2 } + }, + tenantId + ) ).resolves.toEqual({ amount: BigInt(sourceAmount * exampleRates.USD.EUR), scaledExchangeRate: exampleRates.USD.EUR }) await expect( - service.convertSource({ - sourceAmount: BigInt(sourceAmount), - sourceAsset: { code: 'EUR', scale: 2 }, - destinationAsset: { code: 'USD', scale: 2 } - }) + service.convertSource( + { + sourceAmount: BigInt(sourceAmount), + sourceAsset: { code: 'EUR', scale: 2 }, + destinationAsset: { code: 'USD', scale: 2 } + }, + tenantId + ) ).resolves.toEqual({ amount: BigInt(sourceAmount * exampleRates.EUR.USD), scaledExchangeRate: exampleRates.EUR.USD @@ -138,32 +169,41 @@ describe('Rates service', function () { it('returns an error when an asset price is invalid', async () => { await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'MISSING', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'MISSING', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'ZERO', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'ZERO', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) await expect( - service.convertSource({ - sourceAmount: 1234n, - sourceAsset: { code: 'USD', scale: 2 }, - destinationAsset: { code: 'NEGATIVE', scale: 2 } - }) + service.convertSource( + { + sourceAmount: 1234n, + sourceAsset: { code: 'USD', scale: 2 }, + destinationAsset: { code: 'NEGATIVE', scale: 2 } + }, + tenantId + ) ).resolves.toBe(ConvertError.InvalidDestinationPrice) }) }) describe('rates', function () { beforeAll(() => { - mockRatesApi(exchangeRatesUrl, (base) => { + mockRatesApi(tenantExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -196,9 +236,9 @@ describe('Rates service', function () { it('handles concurrent requests for same asset code', async () => { await expect( Promise.all([ - service.rates('USD'), - service.rates('USD'), - service.rates('USD') + service.rates('USD', tenantId), + service.rates('USD', tenantId), + service.rates('USD', tenantId) ]) ).resolves.toEqual([usdRates, usdRates, usdRates]) expect(apiRequestCount).toBe(1) @@ -207,33 +247,33 @@ describe('Rates service', function () { it('handles concurrent requests for different asset codes', async () => { await expect( Promise.all([ - service.rates('USD'), - service.rates('USD'), - service.rates('EUR'), - service.rates('EUR') + service.rates('USD', tenantId), + service.rates('USD', tenantId), + service.rates('EUR', tenantId), + service.rates('EUR', tenantId) ]) ).resolves.toEqual([usdRates, usdRates, eurRates, eurRates]) expect(apiRequestCount).toBe(2) }) it('returns cached request for same asset code', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(1) }) it('returns cached request for different asset codes', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('USD')).resolves.toEqual(usdRates) - await expect(service.rates('EUR')).resolves.toEqual(eurRates) - await expect(service.rates('EUR')).resolves.toEqual(eurRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) + await expect(service.rates('EUR', tenantId)).resolves.toEqual(eurRates) + await expect(service.rates('EUR', tenantId)).resolves.toEqual(eurRates) expect(apiRequestCount).toBe(2) }) it('returns new rates after cache expires', async () => { - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) jest.advanceTimersByTime(exchangeRatesLifetime + 1) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(2) }) @@ -248,10 +288,10 @@ describe('Rates service', function () { throw new Error() }) - await expect(service.rates('USD')).rejects.toThrow( + await expect(service.rates('USD', tenantId)).rejects.toThrow( 'Could not fetch rates' ) - await expect(service.rates('USD')).resolves.toEqual(usdRates) + await expect(service.rates('USD', tenantId)).resolves.toEqual(usdRates) expect(apiRequestCount).toBe(2) }) }) diff --git a/packages/backend/src/rates/service.ts b/packages/backend/src/rates/service.ts index 759dd0e47b..dd556d8d38 100644 --- a/packages/backend/src/rates/service.ts +++ b/packages/backend/src/rates/service.ts @@ -9,6 +9,8 @@ import { } from './util' import { createInMemoryDataStore } from '../middleware/cache/data-stores/in-memory' import { CacheDataStore } from '../middleware/cache/data-stores' +import { TenantSettingService } from '../tenants/settings/service' +import { TenantSettingKeys } from '../tenants/settings/model' const REQUEST_TIMEOUT = 5_000 // millseconds @@ -24,20 +26,27 @@ export type RateConvertDestinationOpts = Omit< > export interface RatesService { - rates(baseAssetCode: string): Promise + rates(baseAssetCode: string, tenantId?: string): Promise convertSource( - opts: RateConvertSourceOpts + opts: RateConvertSourceOpts, + tenantId?: string ): Promise convertDestination( - opts: RateConvertDestinationOpts + opts: RateConvertDestinationOpts, + tenantId?: string ): Promise } interface ServiceDependencies extends BaseService { - // If `url` is not set, the connector cannot convert between currencies. - exchangeRatesUrl?: string + readonly operatorTenantId: string + // Default exchange rates `url` of the operator. + // If tenant doesn't set a specific url in the db, this one will be used. + // In case neither tenant nor operator doesn't set an exchange url, the connector cannot convert between currencies. + operatorExchangeRatesUrl?: string // Duration (milliseconds) that the fetched rates are valid. exchangeRatesLifetime: number + // Used for getting the exchange rates URL from db. + tenantSettingService: TenantSettingService } export enum ConvertError { @@ -54,8 +63,9 @@ export function createRatesService(deps: ServiceDependencies): RatesService { class RatesServiceImpl implements RatesService { private axios: AxiosInstance - private cachedRates: CacheDataStore + private cache: CacheDataStore private inProgressRequests: Record> = {} + private readonly URL_CACHE_PREFIX = 'url:' constructor(private deps: ServiceDependencies) { this.axios = Axios.create({ @@ -63,14 +73,15 @@ class RatesServiceImpl implements RatesService { validateStatus: (status) => status === 200 }) - this.cachedRates = createInMemoryDataStore(deps.exchangeRatesLifetime) + this.cache = createInMemoryDataStore(deps.exchangeRatesLifetime) } async convert( opts: T, convertFn: ( opts: T & { exchangeRate: number } - ) => ConvertResults | ConvertError + ) => ConvertResults | ConvertError, + tenantId: string ): Promise { const { sourceAsset, destinationAsset } = opts const sameCode = sourceAsset.code === destinationAsset.code @@ -89,7 +100,7 @@ class RatesServiceImpl implements RatesService { return convertFn({ ...opts, exchangeRate: 1.0 }) } - const { rates } = await this.getRates(sourceAsset.code) + const { rates } = await this.getRates(sourceAsset.code, tenantId) const destinationExchangeRate = rates[destinationAsset.code] if (!destinationExchangeRate || !isValidPrice(destinationExchangeRate)) { return ConvertError.InvalidDestinationPrice @@ -99,42 +110,59 @@ class RatesServiceImpl implements RatesService { } async convertSource( - opts: RateConvertSourceOpts + opts: RateConvertSourceOpts, + tenantId?: string ): Promise { - return this.convert(opts, convertSource) + return this.convert( + opts, + convertSource, + tenantId ?? this.deps.operatorTenantId + ) } async convertDestination( - opts: RateConvertDestinationOpts + opts: RateConvertDestinationOpts, + tenantId?: string ): Promise { - return this.convert(opts, convertDestination) + return this.convert( + opts, + convertDestination, + tenantId ?? this.deps.operatorTenantId + ) } - async rates(baseAssetCode: string): Promise { - return this.getRates(baseAssetCode) + async rates(baseAssetCode: string, tenantId?: string): Promise { + return this.getRates(baseAssetCode, tenantId ?? this.deps.operatorTenantId) } - private async getRates(baseAssetCode: string): Promise { - const cachedRate = await this.cachedRates.get(baseAssetCode) + private async getRates( + baseAssetCode: string, + tenantId: string + ): Promise { + const ratesCacheKey = `${tenantId}:${baseAssetCode}` + const cachedRate = await this.cache.get(ratesCacheKey) if (cachedRate) { return JSON.parse(cachedRate) } - return await this.fetchNewRatesAndCache(baseAssetCode) + return await this.fetchNewRatesAndCache(baseAssetCode, tenantId) } - private async fetchNewRatesAndCache(baseAssetCode: string): Promise { - const inProgressRequest = this.inProgressRequests[baseAssetCode] - - if (!inProgressRequest) { - this.inProgressRequests[baseAssetCode] = this.fetchNewRates(baseAssetCode) + private async fetchNewRatesAndCache( + baseAssetCode: string, + tenantId: string + ): Promise { + const ratesCacheKey = `${tenantId}:${baseAssetCode}` + if (this.inProgressRequests[ratesCacheKey] === undefined) { + this.inProgressRequests[ratesCacheKey] = this.fetchNewRates( + baseAssetCode, + tenantId + ) } - try { - const rates = await this.inProgressRequests[baseAssetCode] - - await this.cachedRates.set(baseAssetCode, JSON.stringify(rates)) + const rates = await this.inProgressRequests[ratesCacheKey] + await this.cache.set(ratesCacheKey, JSON.stringify(rates)) return rates } catch (err) { const errorMessage = 'Could not fetch rates' @@ -148,19 +176,23 @@ class RatesServiceImpl implements RatesService { errorStatus: err.status } : { err }), - url: this.deps.exchangeRatesUrl + baseAssetCode }, errorMessage ) throw new Error(errorMessage) } finally { - delete this.inProgressRequests[baseAssetCode] + delete this.inProgressRequests[ratesCacheKey] } } - private async fetchNewRates(baseAssetCode: string): Promise { - const url = this.deps.exchangeRatesUrl + private async fetchNewRates( + baseAssetCode: string, + tenantId: string + ): Promise { + const url = await this.getExchangeRatesUrl(tenantId) + if (!url) { return { base: baseAssetCode, rates: {} } } @@ -175,6 +207,37 @@ class RatesServiceImpl implements RatesService { return { base, rates } } + private async getExchangeRatesUrl( + tenantId: string + ): Promise { + const urlCacheKey = `${this.URL_CACHE_PREFIX}${tenantId}` + const cachedUrl = await this.cache.get(urlCacheKey) + if (cachedUrl) { + return cachedUrl + } + + try { + const exchangeUrlSetting = await this.deps.tenantSettingService.get({ + tenantId, + key: TenantSettingKeys.EXCHANGE_RATES_URL.name + }) + + const tenantExchangeRatesUrl = exchangeUrlSetting[0]?.value + if (!tenantExchangeRatesUrl) { + return this.deps.operatorExchangeRatesUrl + } + + await this.cache.set(urlCacheKey, tenantExchangeRatesUrl) + + return tenantExchangeRatesUrl + } catch (error) { + this.deps.logger.error( + { error }, + 'Failed to get exchange rates URL from database' + ) + } + } + private checkBaseAsset(asset: unknown): void { let errorMessage: string | undefined if (!asset) { diff --git a/packages/backend/src/shared/baseModel.ts b/packages/backend/src/shared/baseModel.ts index 94e9c383b7..8a2c33a796 100644 --- a/packages/backend/src/shared/baseModel.ts +++ b/packages/backend/src/shared/baseModel.ts @@ -49,6 +49,7 @@ class PaginationQueryBuilder extends QueryBuilder< * Please read the spec before changing things: * https://relay.dev/graphql/connections.htm * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. * @returns Model[] An array of Models that form a page. */ getPage( diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index 18be4c5541..ae645ecbff 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -35,7 +35,7 @@ describe('Pagination', (): void => { }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -47,6 +47,7 @@ describe('Pagination', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) }) @@ -65,14 +66,15 @@ describe('Pagination', (): void => { first, last, cursor, - 'wallet-address': walletAddress.url + 'wallet-address': walletAddress.address }) - ).toEqual({ ...result, walletAddress: walletAddress.url }) + ).toEqual({ ...result, walletAddress: walletAddress.address }) } ) }) describe('getPageInfo', (): void => { describe('wallet address resources', (): void => { + let tenantId: string let defaultWalletAddress: WalletAddress let secondaryWalletAddress: WalletAddress let debitAmount: Amount @@ -82,11 +84,14 @@ describe('Pagination', (): void => { outgoingPaymentService = await deps.use('outgoingPaymentService') quoteService = await deps.use('quoteService') + tenantId = Config.operatorTenantId const asset = await createAsset(deps) defaultWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) secondaryWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) debitAmount = { @@ -118,7 +123,8 @@ describe('Pagination', (): void => { const paymentIds: string[] = [] for (let i = 0; i < num; i++) { const payment = await createIncomingPayment(deps, { - walletAddressId: defaultWalletAddress.id + walletAddressId: defaultWalletAddress.id, + tenantId: Config.operatorTenantId }) paymentIds.push(payment.id) } @@ -138,7 +144,7 @@ describe('Pagination', (): void => { pagination }), page, - walletAddress: defaultWalletAddress.url + walletAddress: defaultWalletAddress.address }) expect(pageInfo).toEqual({ startCursor: paymentIds[start], @@ -171,8 +177,9 @@ describe('Pagination', (): void => { const paymentIds: string[] = [] for (let i = 0; i < num; i++) { const payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: defaultWalletAddress.id, - receiver: secondaryWalletAddress.url, + receiver: secondaryWalletAddress.address, method: 'ilp', debitAmount, validDestination: false @@ -195,7 +202,7 @@ describe('Pagination', (): void => { pagination }), page, - walletAddress: defaultWalletAddress.url + walletAddress: defaultWalletAddress.address }) expect(pageInfo).toEqual({ startCursor: paymentIds[start], @@ -228,8 +235,9 @@ describe('Pagination', (): void => { const quoteIds: string[] = [] for (let i = 0; i < num; i++) { const quote = await createQuote(deps, { + tenantId, walletAddressId: defaultWalletAddress.id, - receiver: secondaryWalletAddress.url, + receiver: secondaryWalletAddress.address, debitAmount, validDestination: false, method: 'ilp' @@ -252,7 +260,7 @@ describe('Pagination', (): void => { pagination }), page, - walletAddress: defaultWalletAddress.url + walletAddress: defaultWalletAddress.address }) expect(pageInfo).toEqual({ startCursor: quoteIds[start], @@ -300,9 +308,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = assetIds[cursor] else pagination.after = assetIds[cursor] } - const page = await assetService.getPage(pagination) + const page = await assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }) const pageInfo = await getPageInfo({ - getPage: (pagination) => assetService.getPage(pagination), + getPage: (pagination) => + assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }), page }) expect(pageInfo).toEqual({ diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index 1a991d7915..6adebe8a04 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,13 +1,27 @@ +import crypto from 'crypto' import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' -import { isValidHttpUrl, poll, requestWithTimeout, sleep } from './utils' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' +import assert from 'assert' +import { + isValidHttpUrl, + poll, + requestWithTimeout, + sleep, + getTenantFromApiSignature, + ensureTrailingSlash, + urlWithoutTenantId +} from './utils' import { AppServices, AppContext } from '../app' import { TestContainer, createTestApp } from '../tests/app' import { initIocContainer } from '..' import { verifyApiSignature } from './utils' import { generateApiSignature } from '../tests/apiSignature' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { createContext } from '../tests/context' +import { Tenant } from '../tenants/model' +import { truncateTables } from '../tests/tableManager' describe('utils', (): void => { describe('isValidHttpUrl', (): void => { @@ -262,4 +276,194 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('tenant/operator admin api signatures', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let operator: Tenant + let config: IAppConfig + let redis: Redis + + const operatorApiSecret = crypto.randomBytes(8).toString('base64') + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + adminApiSecret: operatorApiSecret + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + redis = await deps.use('redis') + }) + + beforeEach(async (): Promise => { + tenant = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: crypto.randomBytes(8).toString('base64'), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + }) + + operator = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: operatorApiSecret, + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + }) + }) + + afterEach(async (): Promise => { + await redis.flushall() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + test.each` + isOperator | description + ${false} | ${'tenanted non-operator'} + ${true} | ${'tenanted operator'} + `( + 'returns if $description request has valid signature', + async ({ isOperator }): Promise => { + const requestBody = { test: 'value' } + + const signature = isOperator + ? generateApiSignature( + operator.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + : generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': isOperator ? operator.id : tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + assert.ok(result) + expect(result.tenant).toEqual(isOperator ? operator : tenant) + + if (isOperator) { + expect(result.isOperator).toEqual(true) + } else { + expect(result.isOperator).toEqual(false) + } + } + ) + + test("returns undefined when signature isn't signed with tenant secret", async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + 'wrongsecret', + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined + }) + + test('returns undefined if tenant id is not included', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + }) + + test('returns undefined if tenant does not exist', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': v4() + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const tenantService = await deps.use('tenantService') + const getSpy = jest.spyOn(tenantService, 'get') + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + }) + }) + + test('test ensuring trailing slash', async (): Promise => { + const path = '/utils' + + expect(ensureTrailingSlash(path)).toBe(`${path}/`) + expect(ensureTrailingSlash(`${path}/`)).toBe(`${path}/`) + }) + + test('test tenant id stripped from url', async (): Promise => { + expect( + urlWithoutTenantId( + 'http://happy-life-bank-test-auth:4106/cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' + ) + ).toBe('http://happy-life-bank-test-auth:4106') + expect(urlWithoutTenantId('http://happy-life')).toBe('http://happy-life') + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index ab40ea3cc9..9046f7ba06 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -4,6 +4,7 @@ import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' import { IAppConfig } from '../config/app' import { AppContext } from '../app' +import { Tenant } from '../tenants/model' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -97,7 +98,7 @@ export async function poll(args: PollArgs): Promise { } /** - * Omit distrubuted to all types in a union. + * Omit distributed to all types in a union. * @example * type WithoutA = UnionOmit<{ a: number; c: number } | { b: number }, 'a'> // { c: number } | { b: number } * const withoutAOK: WithoutA = { c: 1 } // OK @@ -113,20 +114,17 @@ function getSignatureParts(signature: string) { const signatureParts = signature.split(', ') const timestamp = signatureParts[0].split('=')[1] const signatureVersionAndDigest = signatureParts[1].split('=') - const signatureVersion = signatureVersionAndDigest[0].replace('v', '') - const signatureDigest = signatureVersionAndDigest[1] + const version = signatureVersionAndDigest[0].replace('v', '') + const digest = signatureVersionAndDigest[1] - return { - timestamp, - version: signatureVersion, - digest: signatureDigest - } + return { timestamp, version, digest } } function verifyApiSignatureDigest( signature: string, request: AppContext['request'], - config: IAppConfig + adminApiSignatureVersion: number, + secret: string ): boolean { const { body } = request const { @@ -135,12 +133,12 @@ function verifyApiSignatureDigest( timestamp } = getSignatureParts(signature as string) - if (Number(signatureVersion) !== config.adminApiSignatureVersion) { + if (Number(signatureVersion) !== adminApiSignatureVersion) { return false } const payload = `${timestamp}.${canonicalize(body)}` - const hmac = createHmac('sha256', config.adminApiSecret as string) + const hmac = createHmac('sha256', secret) hmac.update(payload) const digest = hmac.digest('hex') @@ -171,6 +169,53 @@ async function canApiSignatureBeProcessed( return true } +export interface TenantApiSignatureResult { + tenant: Tenant + isOperator: boolean +} + +/* + Verifies http signatures by first attempting to replicate it with a secret + associated with a tenant id in the headers. + + If a tenant secret can replicate the signature, the request is tenanted to that particular tenant. + If the environment admin secret matches the tenant's secret, then it is an operator request with elevated permissions. + If neither can replicate the signature then it is unauthorized. +*/ +export async function getTenantFromApiSignature( + ctx: AppContext, + config: IAppConfig +): Promise { + const { headers } = ctx.request + const signature = headers['signature'] + if (!signature) { + return undefined + } + + const tenantService = await ctx.container.use('tenantService') + const tenantId = headers['tenant-id'] as string + const tenant = tenantId ? await tenantService.get(tenantId) : undefined + + if (!tenant) return undefined + + if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) + return undefined + + if ( + tenant.apiSecret && + verifyApiSignatureDigest( + signature as string, + ctx.request, + config.adminApiSignatureVersion, + tenant.apiSecret + ) + ) { + return { tenant, isOperator: tenant.apiSecret === config.adminApiSecret } + } + + return undefined +} + export async function verifyApiSignature( ctx: AppContext, config: IAppConfig @@ -184,5 +229,23 @@ export async function verifyApiSignature( if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) return false - return verifyApiSignatureDigest(signature as string, ctx.request, config) + return verifyApiSignatureDigest( + signature as string, + ctx.request, + config.adminApiSignatureVersion, + config.adminApiSecret as string + ) +} + +export function ensureTrailingSlash(str: string): string { + if (!str.endsWith('/')) return `${str}/` + return str +} + +/** + * @param url remove the tenant id from the {url} + */ +export function urlWithoutTenantId(url: string): string { + if (url.length > 36 && validateId(url.slice(-36))) return url.slice(0, -37) + return url } diff --git a/packages/backend/src/telemetry/service.test.ts b/packages/backend/src/telemetry/service.test.ts index c599b2d335..7ad67261b4 100644 --- a/packages/backend/src/telemetry/service.test.ts +++ b/packages/backend/src/telemetry/service.test.ts @@ -14,6 +14,11 @@ import { Counter, Histogram } from '@opentelemetry/api' import { privacy } from './privacy' import { mockRatesApi } from '../tests/rates' import { ConvertResults } from '../rates/util' +import { + createTenantSettings, + exchangeRatesSetting +} from '../tests/tenantSettings' +import { CreateOptions } from '../tenants/settings/service' jest.mock('@opentelemetry/api', () => ({ ...jest.requireActual('@opentelemetry/api'), @@ -36,7 +41,7 @@ jest.mock('@opentelemetry/sdk-metrics', () => ({ })) describe('Telemetry Service', () => { - describe('Telemtry Enabled', () => { + describe('Telemetry Enabled', () => { let deps: IocContract let appContainer: TestContainer let telemetryService: TelemetryService @@ -44,7 +49,8 @@ describe('Telemetry Service', () => { let internalRatesService: RatesService let apiRequestCount = 0 - const exchangeRatesUrl = 'http://example-rates.com' + + const tenantId = Config.operatorTenantId const exampleRates = { USD: { @@ -59,7 +65,6 @@ describe('Telemetry Service', () => { deps = initIocContainer({ ...Config, enableTelemetry: true, - telemetryExchangeRatesUrl: 'http://example-rates.com', telemetryExchangeRatesLifetime: 100, openTelemetryCollectors: [] }) @@ -69,7 +74,15 @@ describe('Telemetry Service', () => { aseRatesService = await deps.use('ratesService') internalRatesService = await deps.use('internalRatesService') - mockRatesApi(exchangeRatesUrl, (base) => { + const createOptions: CreateOptions = { + tenantId, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = createTenantSettings(deps, createOptions) + const operatorExchangeRatesUrl = (await tenantSetting).value + + mockRatesApi(operatorExchangeRatesUrl, (base) => { apiRequestCount++ return exampleRates[base as keyof typeof exampleRates] }) @@ -166,7 +179,8 @@ describe('Telemetry Service', () => { value: 100n, assetCode: 'USD', assetScale: 2 - } + }, + tenantId ) expect(spyAseConvert).toHaveBeenCalled() @@ -245,7 +259,8 @@ describe('Telemetry Service', () => { expect.objectContaining({ sourceAmount: source.value, sourceAsset: { code: source.assetCode, scale: source.assetScale } - }) + }), + undefined ) expect(spyConvert).toHaveBeenNthCalledWith( 2, @@ -255,7 +270,8 @@ describe('Telemetry Service', () => { code: destination.assetCode, scale: destination.assetScale } - }) + }), + undefined ) // Ensure the [incrementCounter] was called with the correct calculated value. Expected 5000 due to scale = 4. expect(spyIncCounter).toHaveBeenCalledWith(name, 5000, {}) @@ -289,7 +305,8 @@ describe('Telemetry Service', () => { expect.objectContaining({ sourceAmount: source.value, sourceAsset: { code: source.assetCode, scale: source.assetScale } - }) + }), + undefined ) expect(spyConvert).toHaveBeenNthCalledWith( 2, @@ -299,7 +316,8 @@ describe('Telemetry Service', () => { code: destination.assetCode, scale: destination.assetScale } - }) + }), + undefined ) expect(spyIncCounter).toHaveBeenCalledWith(name, 4400, {}) expect(apiRequestCount).toBe(1) @@ -382,8 +400,7 @@ describe('Telemetry Service', () => { value: 100n, assetCode: 'USD', assetScale: 2 - }, - undefined + } ) expect(applyPrivacySpy).toHaveBeenCalledWith(Number(convertedAmount)) @@ -417,6 +434,7 @@ describe('Telemetry Service', () => { assetScale: 2 }, undefined, + undefined, false ) @@ -480,6 +498,7 @@ describe('Telemetry Service', () => { assetCode: 'USD', assetScale: 2 }, + undefined, { attribute: 'metric attribute' } ) diff --git a/packages/backend/src/telemetry/service.ts b/packages/backend/src/telemetry/service.ts index 0075845ec4..3c80719a7d 100644 --- a/packages/backend/src/telemetry/service.ts +++ b/packages/backend/src/telemetry/service.ts @@ -1,6 +1,5 @@ import { Counter, Histogram, MetricOptions, metrics } from '@opentelemetry/api' import { MeterProvider } from '@opentelemetry/sdk-metrics' - import { RatesService, isConvertError } from '../rates/service' import { ConvertSourceOptions } from '../rates/util' import { BaseService } from '../shared/baseService' @@ -16,6 +15,7 @@ export interface TelemetryService { incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record, preservePrivacy?: boolean ): Promise @@ -23,6 +23,7 @@ export interface TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record ): Promise recordHistogram( @@ -113,30 +114,37 @@ export class TelemetryServiceImpl implements TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {} ): Promise { if (!amountSource.value || !amountDestination.value) return - const convertedSource = await this.convertAmount({ - sourceAmount: amountSource.value, - sourceAsset: { - code: amountSource.assetCode, - scale: amountSource.assetScale - } - }) + const convertedSource = await this.convertAmount( + { + sourceAmount: amountSource.value, + sourceAsset: { + code: amountSource.assetCode, + scale: amountSource.assetScale + } + }, + tenantId + ) if (isConvertError(convertedSource)) { this.deps.logger.error( `Unable to convert source amount: ${convertedSource}` ) return } - const convertedDestination = await this.convertAmount({ - sourceAmount: amountDestination.value, - sourceAsset: { - code: amountDestination.assetCode, - scale: amountDestination.assetScale - } - }) + const convertedDestination = await this.convertAmount( + { + sourceAmount: amountDestination.value, + sourceAsset: { + code: amountDestination.assetCode, + scale: amountDestination.assetScale + } + }, + tenantId + ) if (isConvertError(convertedDestination)) { this.deps.logger.error( `Unable to convert destination amount: ${convertedSource}` @@ -159,15 +167,19 @@ export class TelemetryServiceImpl implements TelemetryService { public async incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {}, preservePrivacy = true ): Promise { const { value, assetCode, assetScale } = amount try { - const converted = await this.convertAmount({ - sourceAmount: value, - sourceAsset: { code: assetCode, scale: assetScale } - }) + const converted = await this.convertAmount( + { + sourceAmount: value, + sourceAsset: { code: assetCode, scale: assetScale } + }, + tenantId + ) if (isConvertError(converted)) { this.deps.logger.error(`Unable to convert amount: ${converted}`) return @@ -195,17 +207,21 @@ export class TelemetryServiceImpl implements TelemetryService { } private async convertAmount( - convertOptions: Pick + convertOptions: Pick, + tenantId?: string ) { const destinationAsset = { code: this.deps.baseAssetCode, scale: this.deps.baseScale } - let converted = await this.aseRatesService.convertSource({ - ...convertOptions, - destinationAsset - }) + let converted = await this.aseRatesService.convertSource( + { + ...convertOptions, + destinationAsset + }, + tenantId + ) if (isConvertError(converted)) { this.deps.logger.error( `Unable to convert amount from provided rates: ${converted}` @@ -263,6 +279,7 @@ export class NoopTelemetryServiceImpl implements TelemetryService { name: string, amountSource: { value: bigint; assetCode: string; assetScale: number }, amountDestination: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes?: Record ): Promise { // do nothing @@ -271,6 +288,7 @@ export class NoopTelemetryServiceImpl implements TelemetryService { public async incrementCounterWithTransactionAmount( name: string, amount: { value: bigint; assetCode: string; assetScale: number }, + tenantId?: string, attributes: Record = {}, preservePrivacy = true ): Promise { diff --git a/packages/backend/src/tenants/errors.ts b/packages/backend/src/tenants/errors.ts new file mode 100644 index 0000000000..5bfba012df --- /dev/null +++ b/packages/backend/src/tenants/errors.ts @@ -0,0 +1,15 @@ +export enum TenantError { + TenantNotFound = 'TenantNotFound', + InvalidTenantId = 'InvalidTenantId' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types +export const isTenantError = (o: any): o is TenantError => + Object.values(TenantError).includes(o) + +export const errorToMessage: { + [key in TenantError]: string +} = { + [TenantError.TenantNotFound]: 'Tenant not found', + [TenantError.InvalidTenantId]: 'Invalid Tenant ID' +} diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts new file mode 100644 index 0000000000..81503dfcb1 --- /dev/null +++ b/packages/backend/src/tenants/model.ts @@ -0,0 +1,39 @@ +import { BaseModel } from '../shared/baseModel' +import { Model, Pojo } from 'objection' +import { TenantSetting } from './settings/model' + +export class Tenant extends BaseModel { + public static get tableName(): string { + return 'tenants' + } + + public static get relationMappings() { + return { + settings: { + relation: Model.HasManyRelation, + modelClass: TenantSetting, + join: { + from: 'tenants.id', + to: 'tenantSettings.tenantId' + } + } + } + } + + public email!: string + public apiSecret!: string + public idpConsentUrl!: string + public idpSecret!: string + public publicName?: string + public settings?: TenantSetting[] + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } +} diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts new file mode 100644 index 0000000000..96af18f7fd --- /dev/null +++ b/packages/backend/src/tenants/service.test.ts @@ -0,0 +1,596 @@ +import assert from 'assert' +import { faker } from '@faker-js/faker' +import { IocContract } from '@adonisjs/fold' +import { Knex } from 'knex' +import { AppServices } from '../app' +import { initIocContainer } from '..' +import { createTestApp, TestContainer } from '../tests/app' +import { TenantService } from './service' +import { Config, IAppConfig } from '../config/app' +import { truncateTables } from '../tests/tableManager' +import { Tenant } from './model' +import { getPageTests } from '../shared/baseModel.test' +import { Pagination, SortOrder } from '../shared/baseModel' +import { createTenant } from '../tests/tenant' +import { CacheDataStore } from '../middleware/cache/data-stores' +import { AuthServiceClient } from '../auth-service-client/client' +import { withConfigOverride } from '../tests/helpers' +import { TenantSetting, TenantSettingKeys } from './settings/model' +import { TenantSettingService } from './settings/service' +import { isTenantError, TenantError } from './errors' +import { v4 } from 'uuid' + +describe('Tenant Service', (): void => { + let deps: IocContract + let config: IAppConfig + let appContainer: TestContainer + let tenantService: TenantService + let knex: Knex + const dbSchema = 'tenant_service_test_schema' + let authServiceClient: AuthServiceClient + let tenantSettingsService: TenantSettingService + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + dbSchema + }) + knex = await deps.use('knex') + config = await deps.use('config') + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + authServiceClient = await deps.use('authServiceClient') + tenantSettingsService = await deps.use('tenantSettingService') + }) + + afterEach(async (): Promise => { + await truncateTables(deps, { truncateTenants: true }) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('Tenant pagination', (): void => { + describe('getPage', (): void => { + getPageTests({ + createModel: () => createTenant(deps), + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => + tenantService.getPage(pagination, sortOrder) + }) + }) + }) + + describe('get', (): void => { + test('can get a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const createdTenant = + await Tenant.query(knex).insertAndFetch(createOptions) + + const tenant = await tenantService.get(createdTenant.id) + assert.ok(tenant) + expect(tenant).toEqual(createdTenant) + }) + + test('returns deletedAt set if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(dbTenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() + }) + + test('returns undefined if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(dbTenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() + }) + }) + + describe('create', (): void => { + test('can create a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + expect(tenant).toEqual(expect.objectContaining(createOptions)) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl + }) + ) + + const tenantSettings = await TenantSetting.query().where( + 'tenantId', + tenant.id + ) + expect(tenantSettings.length).toBeGreaterThan(0) + }) + + test('can create a tenant with a setting', async () => { + const walletAddressUrl = 'https://example.com' + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + settings: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: walletAddressUrl + } + ] + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + const tenantSetting = await TenantSetting.query() + .where('tenantId', tenant.id) + .andWhere('key', TenantSettingKeys.WALLET_ADDRESS_URL.name) + + expect(tenantSetting.length).toBe(1) + expect(tenantSetting[0].value).toEqual(walletAddressUrl) + }) + + test('can create tenant with a specified id', async (): Promise => { + const inputId = v4() + const createOptions = { + id: inputId, + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + expect(tenant.id).toEqual(inputId) + }) + + test('tenant creation rolls back if auth tenant create fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(() => { + throw new Error() + }) + + expect.assertions(3) + let tenant + try { + tenant = await tenantService.create(createOptions) + } catch (err) { + expect(tenant).toBeUndefined() + + const tenants = await Tenant.query() + expect(tenants.length).toEqual(0) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + }) + ) + } + }) + }) + + describe('update', (): void => { + test('can update a tenant', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(originalTenantInfo) + assert(!isTenantError(tenant)) + + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => undefined) + const updatedTenant = await tenantService.update(updatedTenantInfo) + + expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) + expect(spy).toHaveBeenCalledWith(tenant.id, { + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) + }) + + test('rolls back tenant if auth tenant update fails', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(originalTenantInfo) + assert(!isTenantError(tenant)) + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => { + throw new Error() + }) + + let updatedTenant + expect.assertions(3) + try { + updatedTenant = await tenantService.update(updatedTenantInfo) + } catch (err) { + expect(updatedTenant).toBeUndefined() + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) + expect(spy).toHaveBeenCalledWith( + tenant.id, + expect.objectContaining({ + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) + ) + } + }) + + test('Cannot update deleted tenant', async (): Promise => { + const originalSecret = 'test-secret' + const dbTenant = await Tenant.query(knex).insertAndFetch({ + email: faker.internet.url(), + apiSecret: originalSecret, + idpSecret: 'test-idp-secret', + idpConsentUrl: faker.internet.url(), + deletedAt: new Date() + }) + + const spy = jest.spyOn(authServiceClient.tenant, 'update') + try { + await tenantService.update({ + id: dbTenant.id, + apiSecret: 'test-secret-2' + }) + } catch (err) { + const dbTenantAfterUpdate = await Tenant.query(knex).findById( + dbTenant.id + ) + + assert.ok(dbTenantAfterUpdate) + expect(dbTenantAfterUpdate.apiSecret).toEqual(originalSecret) + expect(spy).toHaveBeenCalledTimes(0) + } + }) + }) + + describe('Delete Tenant', (): void => { + test('Can delete tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => undefined) + await tenantService.delete(tenant.id) + + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant?.deletedAt) + expect(dbTenant.deletedAt.getTime()).toBeLessThanOrEqual( + new Date(Date.now()).getTime() + ) + expect(spy).toHaveBeenCalledWith(tenant.id, dbTenant.deletedAt) + + const settings = (await tenantSettingsService.get({ + tenantId: tenant.id + })) as TenantSetting[] + expect(settings.length).toBe(0) + }) + + test('Reverts deletion if auth tenant delete fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => { + throw new Error() + }) + + expect.assertions(3) + try { + await tenantService.delete(tenant.id) + } catch (err) { + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant.id).toEqual(tenant.id) + expect(dbTenant.deletedAt).toBeNull() + expect(spy).toHaveBeenCalledWith(tenant.id, expect.any(Date)) + } + }) + }) + + describe('Tenant Service using cache', (): void => { + let tenantCache: CacheDataStore + let authServiceClient: AuthServiceClient + + beforeAll(async (): Promise => { + tenantCache = await deps.use('tenantCache') + authServiceClient = await deps.use('authServiceClient') + }) + + describe('create, update, and retrieve tenant using cache', (): void => { + test( + 'Tenant can be created, updated, and fetched', + withConfigOverride( + () => config, + { localCacheDuration: 5_000 }, + async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + + const spyCacheSet = jest.spyOn(tenantCache, 'set') + const tenant = await tenantService.create(createOptions) + assert(!isTenantError(tenant)) + expect(tenant).toMatchObject({ + ...createOptions, + id: tenant.id + }) + + // Ensure that the cache was set for create + expect(spyCacheSet).toHaveBeenCalledTimes(1) + + const spyCacheGet = jest.spyOn(tenantCache, 'get') + await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) + + const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + const updatedTenant = await tenantService.update({ + id: tenant.id, + apiSecret: 'test-api-secret-2' + }) + + await expect(tenantService.get(tenant.id)).resolves.toEqual( + updatedTenant + ) + + // Ensure that cache was set for update + expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) + expect(spyCacheUpdateSet).toHaveBeenCalledWith( + tenant.id, + updatedTenant + ) + + const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + await tenantService.delete(tenant.id) + + await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() + + // Ensure that cache was set for deletion + expect(spyCacheDelete).toHaveBeenCalledTimes(1) + expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(tenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() + } + ) + ) + }) + }) +}) + +describe('Tenant Service (no tenant truncate)', (): void => { + let deps: IocContract + let config: IAppConfig + let appContainer: TestContainer + let tenantService: TenantService + let knex: Knex + let tenantCache: CacheDataStore + let updateSpyWasCalled: boolean + const dbSchema = 'tenant_service_test_schema2' + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + dbSchema + }) + knex = await deps.use('knex') + config = await deps.use('config') + tenantService = await deps.use('tenantService') + tenantCache = await deps.use('tenantCache') + + const updateOperatorSecretSpy = jest.spyOn( + tenantService, + 'updateOperatorApiSecretFromConfig' + ) + appContainer = await createTestApp(deps) + updateSpyWasCalled = updateOperatorSecretSpy.mock.calls.length > 0 + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + describe('updateOperatorApiSecretFromConfig', () => { + test('called on application start', async (): Promise => { + expect(updateSpyWasCalled).toBe(true) + }) + + test('updates secret if changed', async (): Promise => { + // Setup operator with different secret than the config. + // As-if the api secret was set from a different config value originally. + const initialApiSecret = '123' + assert(initialApiSecret !== config.adminApiSecret) + const tenant = await Tenant.query(knex).patchAndFetchById( + config.operatorTenantId, + { apiSecret: initialApiSecret } + ) + assert(tenant) + expect(tenant.apiSecret).toBe(initialApiSecret) + + const error = await tenantService.updateOperatorApiSecretFromConfig() + expect(error).toBe(undefined) + + const updated = await Tenant.query(knex).findById(tenant.id) + assert(updated) + expect(updated.apiSecret).toBe(config.adminApiSecret) + + const cacheUpdated = await tenantCache.get(tenant.id) + assert(cacheUpdated) + expect(cacheUpdated.apiSecret).toBe(config.adminApiSecret) + }) + test('does not update if secret hasnt changed', async (): Promise => { + const tenant = await Tenant.query(knex).findById(config.operatorTenantId) + assert(tenant) + assert(tenant.apiSecret === config.adminApiSecret) + + const error = await tenantService.updateOperatorApiSecretFromConfig() + + expect(error).toBe(undefined) + + const updated = await Tenant.query(knex).findById(tenant.id) + assert(updated) + expect(updated.updatedAt).toStrictEqual(tenant.updatedAt) + }) + test( + 'throws error if operator tenant not found', + withConfigOverride( + () => config, + { operatorTenantId: crypto.randomUUID() }, + async (): Promise => { + const error = await tenantService.updateOperatorApiSecretFromConfig() + + expect(isTenantError(error)).toBe(true) + expect(error).toEqual(TenantError.TenantNotFound) + } + ) + ) + }) +}) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts new file mode 100644 index 0000000000..94e0d8ed97 --- /dev/null +++ b/packages/backend/src/tenants/service.ts @@ -0,0 +1,241 @@ +import { validate as validateUuid } from 'uuid' +import { Tenant } from './model' +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../shared/baseModel' +import { CacheDataStore } from '../middleware/cache/data-stores' +import type { AuthServiceClient } from '../auth-service-client/client' +import { KeyValuePair, TenantSettingService } from './settings/service' +import { TenantSetting, TenantSettingKeys } from './settings/model' +import type { IAppConfig } from '../config/app' +import { isTenantError, TenantError } from './errors' +import { TenantSettingInput } from '../graphql/generated/graphql' + +export interface TenantService { + get: (id: string, includeDeleted?: boolean) => Promise + create: (options: CreateTenantOptions) => Promise + update: (options: UpdateTenantOptions) => Promise + delete: (id: string) => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise + updateOperatorApiSecretFromConfig: () => Promise +} +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex + tenantCache: CacheDataStore + authServiceClient: AuthServiceClient + tenantSettingService: TenantSettingService + config: IAppConfig +} + +export async function createTenantService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantService' }) + } + + return { + get: (id: string, includeDeleted?: boolean) => + getTenant(deps, id, includeDeleted), + create: (options) => createTenant(deps, options), + update: (options) => updateTenant(deps, options), + delete: (id) => deleteTenant(deps, id), + getPage: (pagination, sortOrder) => + getTenantPage(deps, pagination, sortOrder), + updateOperatorApiSecretFromConfig: () => + updateOperatorApiSecretFromConfig(deps) + } +} + +async function getTenant( + deps: ServiceDependencies, + id: string, + includeDeleted: boolean = false +): Promise { + const inMem = await deps.tenantCache.get(id) + if (inMem) { + if (!includeDeleted && inMem.deletedAt) return undefined + return inMem + } + let query = Tenant.query(deps.knex) + if (!includeDeleted) query = query.whereNull('deletedAt') + + const tenant = await query.findById(id) + if (tenant) await deps.tenantCache.set(tenant.id, tenant) + + return tenant +} + +async function getTenantPage( + deps: ServiceDependencies, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await Tenant.query(deps.knex).getPage(pagination, sortOrder) +} + +interface CreateTenantOptions { + id?: string + email?: string + apiSecret: string + idpSecret?: string + idpConsentUrl?: string + publicName?: string + settings?: TenantSettingInput[] +} + +async function createTenant( + deps: ServiceDependencies, + options: CreateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + try { + const { + id, + email, + apiSecret, + publicName, + idpSecret, + idpConsentUrl, + settings + } = options + if (id && !validateUuid(id)) { + throw TenantError.InvalidTenantId + } + const tenant = await Tenant.query(trx).insertAndFetch({ + id, + email, + publicName, + apiSecret, + idpSecret, + idpConsentUrl + }) + + await deps.authServiceClient.tenant.create({ + id: tenant.id, + idpSecret, + idpConsentUrl + }) + + const createInitialTenantSettingsOptions = { + tenantId: tenant.id, + setting: TenantSetting.default() + } + + const defaultIlpAddressSetting: KeyValuePair = { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: `${deps.config.ilpAddress}.${tenant.id}` + } + + createInitialTenantSettingsOptions.setting.push(defaultIlpAddressSetting) + + if (settings) { + createInitialTenantSettingsOptions.setting = + createInitialTenantSettingsOptions.setting.concat(settings) + } + + await deps.tenantSettingService.create(createInitialTenantSettingsOptions, { + trx + }) + + await trx.commit() + + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + if (isTenantError(err)) return err + throw err + } +} + +interface UpdateTenantOptions { + id: string + email?: string + publicName?: string + apiSecret?: string + idpConsentUrl?: string + idpSecret?: string +} + +async function updateTenant( + deps: ServiceDependencies, + options: UpdateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + + try { + const { id, apiSecret, email, publicName, idpConsentUrl, idpSecret } = + options + const tenant = await Tenant.query(trx) + .patchAndFetchById(options.id, { + email, + publicName, + apiSecret, + idpConsentUrl, + idpSecret + }) + .whereNull('deletedAt') + .throwIfNotFound() + + if (idpConsentUrl || idpSecret) { + await deps.authServiceClient.tenant.update(id, { + idpConsentUrl, + idpSecret + }) + } + + await trx.commit() + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string +): Promise { + const trx = await deps.knex.transaction() + + await deps.tenantCache.delete(id) + try { + const deletedAt = new Date() + + await deps.tenantSettingService.delete( + { + tenantId: id + }, + { trx, deletedAt } + ) + await Tenant.query(trx).patchAndFetchById(id, { + deletedAt + }) + await deps.authServiceClient.tenant.delete(id, deletedAt) + await trx.commit() + } catch (err) { + await trx.rollback() + throw err + } +} + +async function updateOperatorApiSecretFromConfig( + deps: ServiceDependencies +): Promise { + const { adminApiSecret, operatorTenantId } = deps.config + + const tenant = await Tenant.query(deps.knex) + .findById(operatorTenantId) + .whereNull('deletedAt') + + if (!tenant) { + return TenantError.TenantNotFound + } + if (tenant.apiSecret !== adminApiSecret) { + await tenant.$query(deps.knex).patch({ apiSecret: adminApiSecret }) + await deps.tenantCache.set(operatorTenantId, tenant) + } +} diff --git a/packages/backend/src/tenants/settings/errors.ts b/packages/backend/src/tenants/settings/errors.ts new file mode 100644 index 0000000000..6d0680a2ad --- /dev/null +++ b/packages/backend/src/tenants/settings/errors.ts @@ -0,0 +1,28 @@ +import { GraphQLErrorCode } from '../../graphql/errors' + +export enum TenantSettingError { + TenantNotFound = 'TenantNotFound', + UnknownError = 'UnknownError', + InvalidSetting = 'InvalidSettingError' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isTenantSettingError = (t: any): t is TenantSettingError => + Object.values(TenantSettingError).includes(t) + +export const errorToCode: { + [key in TenantSettingError]: GraphQLErrorCode +} = { + [TenantSettingError.InvalidSetting]: GraphQLErrorCode.BadUserInput, + [TenantSettingError.TenantNotFound]: GraphQLErrorCode.NotFound, + [TenantSettingError.UnknownError]: GraphQLErrorCode.InternalServerError +} + +export const errorToMessage: { + [key in TenantSettingError]: string +} = { + [TenantSettingError.TenantNotFound]: 'Tenant not found', + [TenantSettingError.UnknownError]: 'Unknown error', + [TenantSettingError.InvalidSetting]: + 'Invalid value for one or more tenant settings' +} diff --git a/packages/backend/src/tenants/settings/model.test.ts b/packages/backend/src/tenants/settings/model.test.ts new file mode 100644 index 0000000000..0e7502e397 --- /dev/null +++ b/packages/backend/src/tenants/settings/model.test.ts @@ -0,0 +1,76 @@ +import assert from 'assert' +import { IocContract } from '@adonisjs/fold' +import { TenantSetting, TenantSettingKeys, formatSettings } from './model' +import { AppServices } from '../../app' +import { createTestApp, TestContainer } from '../../tests/app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { truncateTables } from '../../tests/tableManager' +import { createTenant } from '../../tests/tenant' +import { faker } from '@faker-js/faker' + +describe('TenantSetting Model', (): void => { + describe('default', () => { + test('can specify default settings', async (): Promise => { + expect(TenantSetting.default()).toEqual([ + { key: 'WEBHOOK_TIMEOUT', value: '2000' }, + { key: 'WEBHOOK_MAX_RETRY', value: '10' } + ]) + }) + }) + + describe('formatting', () => { + let deps: IocContract + let appContainer: TestContainer + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + }) + + afterAll(async (): Promise => { + await truncateTables(appContainer.container) + await appContainer.shutdown() + }) + test('can format tenant settings', async (): Promise => { + const tenant = await createTenant(deps) + const webhookUrlSetting = await TenantSetting.query().insertAndFetch({ + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_URL.name, + value: faker.internet.url() + }) + + const webhookMaxRetrySetting = await TenantSetting.query().findOne({ + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '10' + }) + assert.ok(webhookMaxRetrySetting) + + const webhookTimeoutSetting = await TenantSetting.query().findOne({ + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name + }) + assert.ok(webhookTimeoutSetting) + + const exchangeRateSetting = await TenantSetting.query().insertAndFetch({ + tenantId: tenant.id, + key: TenantSettingKeys.EXCHANGE_RATES_URL.name, + value: faker.internet.url() + }) + + const formattedSettings = formatSettings([ + webhookUrlSetting, + webhookMaxRetrySetting, + webhookTimeoutSetting, + exchangeRateSetting + ]) + expect(formattedSettings).toMatchObject({ + exchangeRatesUrl: exchangeRateSetting.value, + webhookUrl: webhookUrlSetting.value, + webhookMaxRetry: webhookMaxRetrySetting.value, + webhookTimeout: webhookTimeoutSetting.value + }) + }) + }) +}) diff --git a/packages/backend/src/tenants/settings/model.ts b/packages/backend/src/tenants/settings/model.ts new file mode 100644 index 0000000000..b64cd5b6dc --- /dev/null +++ b/packages/backend/src/tenants/settings/model.ts @@ -0,0 +1,109 @@ +import { Pojo } from 'objection' +import { BaseModel } from '../../shared/baseModel' +import { KeyValuePair } from './service' +import { isValidIlpAddress } from 'ilp-packet' + +interface TenantSettingKeyType { + name: string + default?: unknown +} + +export const TenantSettingKeys: { [key: string]: TenantSettingKeyType } = { + EXCHANGE_RATES_URL: { name: 'EXCHANGE_RATES_URL' }, + WEBHOOK_URL: { name: 'WEBHOOK_URL' }, + WEBHOOK_TIMEOUT: { name: 'WEBHOOK_TIMEOUT', default: 2000 }, + WEBHOOK_MAX_RETRY: { name: 'WEBHOOK_MAX_RETRY', default: 10 }, + WALLET_ADDRESS_URL: { name: 'WALLET_ADDRESS_URL' }, + ILP_ADDRESS: { name: 'ILP_ADDRESS' } +} + +export class TenantSetting extends BaseModel { + public static get tableName(): string { + return 'tenantSettings' + } + + public key!: string + public value!: string + public tenantId!: string + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } + + static default(): KeyValuePair[] { + const settings = [] + for (const key of Object.keys(TenantSettingKeys)) { + const data = TenantSettingKeys[key] + if (!data.default) { + continue + } + + settings.push({ + key: data.name, + value: String(data.default) + }) + } + + return settings + } +} + +const TENANT_KEY_MAPPING = { + [TenantSettingKeys.EXCHANGE_RATES_URL.name]: 'exchangeRatesUrl', + [TenantSettingKeys.WEBHOOK_MAX_RETRY.name]: 'webhookMaxRetry', + [TenantSettingKeys.WEBHOOK_TIMEOUT.name]: 'webhookTimeout', + [TenantSettingKeys.WEBHOOK_URL.name]: 'webhookUrl', + [TenantSettingKeys.WALLET_ADDRESS_URL.name]: 'walletAddressUrl', + [TenantSettingKeys.ILP_ADDRESS.name]: 'ilpAddress' +} as const + +export type FormattedTenantSettings = Record< + (typeof TENANT_KEY_MAPPING)[keyof typeof TENANT_KEY_MAPPING], + TenantSetting['value'] +> + +export const formatSettings = ( + settings: TenantSetting[] +): Partial => { + const settingsObj: Partial = {} + for (const setting of settings) { + const { key } = setting + settingsObj[TENANT_KEY_MAPPING[key]] = setting.value + } + return settingsObj +} + +const validateUrlTenantSetting = (url: string): boolean => { + try { + return !!new URL(url) + } catch (err) { + return false + } +} + +const validateIlpAddressTenantSetting = (ilpAddress: string): boolean => { + return isValidIlpAddress(ilpAddress) +} + +const validateNonNegativeTenantSetting = (numberString: string): boolean => { + return !!(Number.isFinite(Number(numberString)) && Number(numberString) > -1) +} + +const validatePositiveTenantSetting = (numberString: string): boolean => { + return !!(Number.isFinite(Number(numberString)) && Number(numberString) > 0) +} + +export const TENANT_SETTING_VALIDATORS = { + [TenantSettingKeys.EXCHANGE_RATES_URL.name]: validateUrlTenantSetting, + [TenantSettingKeys.WEBHOOK_MAX_RETRY.name]: validateNonNegativeTenantSetting, + [TenantSettingKeys.WEBHOOK_TIMEOUT.name]: validatePositiveTenantSetting, + [TenantSettingKeys.WEBHOOK_URL.name]: validateUrlTenantSetting, + [TenantSettingKeys.WALLET_ADDRESS_URL.name]: validateUrlTenantSetting, + [TenantSettingKeys.ILP_ADDRESS.name]: validateIlpAddressTenantSetting +} diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts new file mode 100644 index 0000000000..27f86eb507 --- /dev/null +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -0,0 +1,732 @@ +import { IocContract } from '@adonisjs/fold' +import assert from 'assert' +import { AppServices } from '../../app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { createTestApp, TestContainer } from '../../tests/app' +import nock from 'nock' +import { truncateTables } from '../../tests/tableManager' +import { Tenant } from '../model' +import { TenantService } from '../service' +import { faker } from '@faker-js/faker' +import { exchangeRatesSetting, randomSetting } from '../../tests/tenantSettings' +import { TenantSetting, TenantSettingKeys } from './model' +import { + CreateOptions, + GetOptions, + TenantSettingService, + UpdateOptions +} from './service' +import { AuthServiceClient } from '../../auth-service-client/client' +import { v4 as uuid } from 'uuid' +import { createTenant } from '../../tests/tenant' +import { isTenantSettingError, TenantSettingError } from './errors' +import { isTenantError } from '../errors' + +describe('TenantSetting Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let tenantService: TenantService + let tenantSettingService: TenantSettingService + let authServiceClient: AuthServiceClient + + const dbSchema = 'tenant_settings_service_test_schema' + + beforeAll(async (): Promise => { + deps = initIocContainer({ ...Config, dbSchema }) + appContainer = await createTestApp(deps) + + tenantService = await deps.use('tenantService') + tenantSettingService = await deps.use('tenantSettingService') + authServiceClient = await deps.use('authServiceClient') + }) + + beforeEach(async (): Promise => { + jest + .spyOn(authServiceClient.tenant, 'create') + .mockResolvedValueOnce(undefined) + + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockResolvedValueOnce(undefined) + + const tenantOrError = await tenantService.create({ + apiSecret: faker.string.uuid(), + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.uuid() + }) + assert(!isTenantError(tenantOrError)) + tenant = tenantOrError + }) + + afterEach(async (): Promise => { + await truncateTables(deps, { truncateTenants: true }) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + describe('create', () => { + test('can create a tenant setting', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key: createOptions.setting[0].key, + value: createOptions.setting[0].value + }) + ]) + }) + + test('returns empty array if setting key is not allowed', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([]) + }) + + test('should update existing tenant settings on conflict - upsert', async (): Promise => { + const initialOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + await tenantSettingService.create(initialOptions) + + const newValue = faker.internet.url() + const updatedOptions: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: initialOptions.setting[0].key, + value: newValue + } + ] + } + + await tenantSettingService.create(updatedOptions) + const result = (await tenantSettingService.get({ + tenantId: tenant.id, + key: initialOptions.setting[0].key + })) as TenantSetting[] + + expect(result).toHaveLength(1) + expect(result[0].key).toEqual(initialOptions.setting[0].key) + expect(result[0].value).toEqual(newValue) + }) + + test.each` + key + ${TenantSettingKeys.EXCHANGE_RATES_URL.name} + ${TenantSettingKeys.WEBHOOK_MAX_RETRY.name} + ${TenantSettingKeys.WEBHOOK_TIMEOUT.name} + ${TenantSettingKeys.WEBHOOK_URL.name} + ${TenantSettingKeys.WALLET_ADDRESS_URL.name} + `( + 'cannot use invalid setting value for $key', + async ({ key }): Promise => { + const invalidSettingOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key, + value: 'invalid_value' + } + ] + } + + await expect( + tenantSettingService.create(invalidSettingOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + } + ) + + test.each` + key + ${TenantSettingKeys.EXCHANGE_RATES_URL.name} + ${TenantSettingKeys.WEBHOOK_URL.name} + ${TenantSettingKeys.WALLET_ADDRESS_URL.name} + `( + 'accepts URL string for $key tenant setting', + async ({ key }): Promise => { + const url = faker.internet.url() + const createOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key, + value: url + } + ] + } + + const tenantSetting = await tenantSettingService.create(createOption) + expect(tenantSetting).toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key, + value: url + }) + ]) + } + ) + + test('cannot use invalid numeric values for positive tenant settings', async (): Promise => { + const infiniteOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: 'Infinity' + } + ] + } + + await expect( + tenantSettingService.create(infiniteOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + + const zeroOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: '0' + } + ] + } + + await expect(tenantSettingService.create(zeroOption)).resolves.toEqual( + TenantSettingError.InvalidSetting + ) + }) + + test('cannot use invalid numeric values for non-negative numeric tenant settings', async (): Promise => { + const infiniteOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: 'Infinity' + } + ] + } + + await expect( + tenantSettingService.create(infiniteOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + + const negativeOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '-1' + } + ] + } + + await expect( + tenantSettingService.create(negativeOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + }) + + test('accepts valid ILP address for ILP address tenant setting', async (): Promise => { + const invalidIlpAddressSetting: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.net' + } + ] + } + + await expect( + tenantSettingService.create(invalidIlpAddressSetting) + ).resolves.toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test.net' + }) + ]) + }) + + test('cannot use invalid ILP address for ILP address tenant setting', async (): Promise => { + const invalidIlpAddressSetting: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.ILP_ADDRESS.name, + value: 'test' + } + ] + } + + await expect( + tenantSettingService.create(invalidIlpAddressSetting) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + }) + }) + + describe('get', () => { + let tenantSetting: TenantSetting[] + + async function createTenantSetting(): Promise { + const options: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + const createdTenantSetting = await tenantSettingService.create(options) + assert(!isTenantSettingError(createdTenantSetting)) + return createdTenantSetting + } + + beforeEach(async (): Promise => { + await createTenantSetting() + tenantSetting = (await tenantSettingService.get({ + tenantId: tenant.id + })) as TenantSetting[] + }) + + afterEach(async (): Promise => { + return tenantSettingService.delete({ + tenantId: tenant.id + }) + }) + + test('should get tenant setting', async () => { + const dbTenantSetting = await tenantSettingService.get({ + tenantId: tenant.id, + key: tenantSetting[0].key + }) + + expect(dbTenantSetting).toEqual([tenantSetting[0]]) + }) + + test('should get all tenant settings', async () => { + const newTenantSetting = await createTenantSetting() + const dbTenantSettings = await tenantSettingService.get({ + tenantId: tenant.id + }) + + const settings = tenantSetting.concat(newTenantSetting) + + expect(dbTenantSettings).toEqual(settings) + }) + + test('should not get deleted tenant', async () => { + const options: GetOptions = { + tenantId: tenant.id, + key: tenantSetting[0].key + } + + await tenantSettingService.delete(options) + const dbTenantSetting = await tenantSettingService.get(options) + + expect(dbTenantSetting).toHaveLength(0) + }) + }) + + describe('update', () => { + let updateOptions: UpdateOptions + + beforeEach(async () => { + updateOptions = { + tenantId: tenant.id, + ...exchangeRatesSetting() + } + + await tenantSettingService.create({ + tenantId: updateOptions.tenantId, + setting: [{ key: updateOptions.key, value: updateOptions.value }] + }) + }) + + test('can update own setting', async () => { + const newValues = { + ...updateOptions, + value: faker.internet.url() + } + await tenantSettingService.update(newValues) + + const res = await tenantSettingService.get({ + tenantId: newValues.tenantId, + key: newValues.key + }) + + expect(res).toEqual( + expect.arrayContaining([expect.objectContaining(newValues)]) + ) + }) + + test.each` + key + ${TenantSettingKeys.EXCHANGE_RATES_URL.name} + ${TenantSettingKeys.WEBHOOK_MAX_RETRY.name} + ${TenantSettingKeys.WEBHOOK_TIMEOUT.name} + ${TenantSettingKeys.WEBHOOK_URL.name} + ${TenantSettingKeys.WALLET_ADDRESS_URL.name} + `( + 'cannot use invalid setting value for $key', + async ({ key }): Promise => { + const invalidSettingOption: UpdateOptions = { + tenantId: tenant.id, + key, + value: 'invalid_value' + } + + await expect( + tenantSettingService.update(invalidSettingOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + } + ) + + test.each` + key + ${TenantSettingKeys.EXCHANGE_RATES_URL.name} + ${TenantSettingKeys.WEBHOOK_URL.name} + ${TenantSettingKeys.WALLET_ADDRESS_URL.name} + `( + 'accepts URL string for $key tenant setting', + async ({ key }): Promise => { + const createOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key, + value: faker.internet.url() + } + ] + } + await tenantSettingService.create(createOption) + + const url = faker.internet.url() + const updateOption: UpdateOptions = { + tenantId: tenant.id, + key, + value: url + } + + const updatedTenantSetting = + await tenantSettingService.update(updateOption) + expect(updatedTenantSetting).toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key, + value: url + }) + ]) + } + ) + + test('cannot use invalid numeric values for positive numeric tenant settings', async (): Promise => { + const createOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: '5000' + } + ] + } + await tenantSettingService.create(createOption) + const infiniteOption: UpdateOptions = { + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: 'Infinity' + } + + await expect( + tenantSettingService.update(infiniteOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + + const zeroOption: UpdateOptions = { + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: '0' + } + + await expect(tenantSettingService.update(zeroOption)).resolves.toEqual( + TenantSettingError.InvalidSetting + ) + }) + + test('cannot use invalid numeric values for non-negative numeric tenant settings', async (): Promise => { + const createOption: CreateOptions = { + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '10' + } + ] + } + + await tenantSettingService.create(createOption) + + const infiniteOption: UpdateOptions = { + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: 'Infinity' + } + + await expect( + tenantSettingService.update(infiniteOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + + const negativeOption: UpdateOptions = { + tenantId: tenant.id, + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '-1' + } + + await expect( + tenantSettingService.update(negativeOption) + ).resolves.toEqual(TenantSettingError.InvalidSetting) + }) + }) + + describe('delete', (): void => { + describe('delete tenant', () => { + it('should delete tenant settings if tenant is deleted', async () => { + await tenantService.delete(tenant.id) + const found = await Tenant.query() + .findById(tenant.id) + .withGraphFetched('settings') + + for (const tenantSetting of found?.settings as TenantSetting[]) { + expect(found?.deletedAt).toEqual(tenantSetting.deletedAt) + } + }) + }) + test('can delete tenant setting key', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSettingsOrError = + await tenantSettingService.create(createOptions) + assert(!isTenantSettingError(tenantSettingsOrError)) + await tenantSettingService.delete({ + tenantId: tenantSettingsOrError[0].tenantId, + key: createOptions.setting[0].key + }) + + const dbTenantSetting = await TenantSetting.query().findById( + tenantSettingsOrError[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + expect(dbTenantSetting?.deletedAt?.getTime()).toBeLessThanOrEqual( + Date.now() + ) + }) + + test('cannot delete already deleted setting', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + assert(!isTenantSettingError(tenantSetting)) + await tenantSettingService.delete({ + tenantId: tenantSetting[0].tenantId, + key: createOptions.setting[0].key + }) + + let dbTenantSetting = await TenantSetting.query().findById( + tenantSetting[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + + const originalDeletedAt = dbTenantSetting?.deletedAt + await tenantSettingService.delete({ + tenantId: tenantSetting[0].tenantId, + key: createOptions.setting[0].key + }) + + dbTenantSetting = await TenantSetting.query().findById( + tenantSetting[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + + expect(originalDeletedAt?.getTime()).toEqual( + dbTenantSetting?.deletedAt?.getTime() + ) + }) + + test('can delete all tenant settings', async (): Promise => { + for (let i = 0; i < 10; i++) { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + await tenantSettingService.create(createOptions) + } + + await tenantSettingService.delete({ tenantId: tenant.id }) + + const dbTenantData = await TenantSetting.query().where({ + tenantId: tenant.id + }) + + expect(dbTenantData.filter((x) => !x.deletedAt)).toHaveLength(0) + }) + }) + + describe('getTenantSettings', () => { + let tenantSetting: TenantSetting[] + + beforeEach(async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const createdTenantSetting = + await tenantSettingService.create(createOptions) + assert(!isTenantSettingError(createdTenantSetting)) + tenantSetting = createdTenantSetting + }) + + afterEach(async (): Promise => { + await tenantSettingService.delete({ tenantId: tenant.id }) + }) + + test('should retrieve tenant settings by tenantId', async (): Promise => { + const result = await tenantSettingService.get({ + tenantId: tenant.id + }) + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tenantId: tenant.id, + key: tenantSetting[0].key, + value: tenantSetting[0].value + }) + ]) + ) + }) + + test('should retrieve tenant settings by tenantId and key', async (): Promise => { + const result = await tenantSettingService.get({ + tenantId: tenant.id, + key: tenantSetting[0].key + }) + + expect(result).toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key: tenantSetting[0].key, + value: tenantSetting[0].value + }) + ]) + }) + + test('should return an empty array if no settings match', async (): Promise => { + const result = await tenantSettingService.get({ + tenantId: tenant.id, + key: 'nonexistent-key' + }) + + expect(result).toEqual([]) + }) + + test('should not retrieve deleted tenant settings', async (): Promise => { + await tenantSettingService.delete({ + tenantId: tenant.id, + key: tenantSetting[0].key + }) + + const result = await tenantSettingService.get({ + tenantId: tenant.id, + key: tenantSetting[0].key + }) + + expect(result).toEqual([]) + }) + }) + + describe('get settings by value', (): void => { + test('can get settings by wallet address prefix setting', async (): Promise => { + const secondTenant = await createTenant(deps) + const baseUrl = `https://${faker.internet.domainName()}/${uuid()}` + const settings = ( + await Promise.all([ + tenantSettingService.create({ + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: `${baseUrl}/${uuid()}` + } + ] + }), + tenantSettingService.create({ + tenantId: secondTenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: `${baseUrl}/${uuid()}` + } + ] + }) + ]) + ).flat() + + const retrievedSettings = + await tenantSettingService.getSettingsByPrefix(baseUrl) + expect(retrievedSettings).toEqual(settings) + }) + + test('does not retrieve tenants if no wallet address prefix matches', async (): Promise => { + const secondTenant = await createTenant(deps) + const baseUrl = `https://${faker.internet.domainName()}/${uuid()}` + await Promise.all([ + tenantSettingService.create({ + tenantId: tenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: `${baseUrl}/${uuid()}` + } + ] + }), + tenantSettingService.create({ + tenantId: secondTenant.id, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: `${baseUrl}/${uuid()}` + } + ] + }) + ]) + + const retrievedSettings = await tenantSettingService.getSettingsByPrefix( + faker.internet.url() + ) + expect(retrievedSettings).toHaveLength(0) + }) + }) +}) diff --git a/packages/backend/src/tenants/settings/service.ts b/packages/backend/src/tenants/settings/service.ts new file mode 100644 index 0000000000..ae6fc437e1 --- /dev/null +++ b/packages/backend/src/tenants/settings/service.ts @@ -0,0 +1,185 @@ +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../../shared/baseModel' +import { BaseService } from '../../shared/baseService' +import { + TENANT_SETTING_VALIDATORS, + TenantSetting, + TenantSettingKeys +} from './model' +import { Knex } from 'knex' +import { TenantSettingError } from './errors' + +export interface KeyValuePair { + key: string + value: string +} + +export interface UpdateOptions { + tenantId: string + key: string + value: string +} + +export interface CreateOptions { + tenantId: string + setting: KeyValuePair[] +} + +export interface GetOptions { + tenantId: string + key?: string +} + +export interface ExtraOptions { + trx?: Knex.Transaction + deletedAt?: Date +} + +export interface TenantSettingService { + get: (options: GetOptions) => Promise + create: ( + options: CreateOptions, + extra?: ExtraOptions + ) => Promise + update: ( + options: UpdateOptions + ) => Promise + delete: (options: GetOptions, extra?: ExtraOptions) => Promise + getPage: ( + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder + ) => Promise + getSettingsByPrefix: (prefix: string) => Promise +} + +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createTenantSettingService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantSettingService ' }) + } + + return { + get: (options: GetOptions) => getTenantSettings(deps, options), + create: (options: CreateOptions, extra?: ExtraOptions) => + createTenantSetting(deps, options, extra), + update: (options: UpdateOptions) => updateTenantSetting(deps, options), + delete: (options: GetOptions, extra?: ExtraOptions) => + deleteTenantSetting(deps, options, extra), + getPage: ( + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder + ) => getTenantSettingPageForTenant(deps, tenantId, pagination, sortOrder), + getSettingsByPrefix: (prefix: string) => + getWalletAddressSettingsByPrefix(deps, prefix) + } +} + +async function getTenantSettings( + deps: ServiceDependencies, + options: GetOptions +): Promise { + return TenantSetting.query(deps.knex).whereNull('deletedAt').andWhere(options) +} + +async function deleteTenantSetting( + deps: ServiceDependencies, + options: GetOptions, + extra?: ExtraOptions +) { + const obj: GetOptions = { + tenantId: options.tenantId + } + + if (options.key) { + obj.key = options.key + } + + await TenantSetting.query(extra?.trx ?? deps.knex) + .findOne(obj) + .whereNull('deletedAt') + .patch({ + deletedAt: extra?.deletedAt ?? new Date() + }) +} + +async function updateTenantSetting( + deps: ServiceDependencies, + options: UpdateOptions +): Promise { + if ( + Object.keys(TENANT_SETTING_VALIDATORS).includes(options.key) && + !TENANT_SETTING_VALIDATORS[options.key](options.value) + ) { + return TenantSettingError.InvalidSetting + } + return TenantSetting.query(deps.knex) + .patch({ value: options.value }) + .whereNull('deletedAt') + .andWhere('tenantId', options.tenantId) + .andWhere('key', options.key) + .returning('*') + .throwIfNotFound() +} + +async function createTenantSetting( + deps: ServiceDependencies, + options: CreateOptions, + extra?: ExtraOptions +): Promise { + for (const setting of options.setting) { + if ( + Object.keys(TENANT_SETTING_VALIDATORS).includes(setting.key) && + !TENANT_SETTING_VALIDATORS[setting.key](setting.value) + ) { + return TenantSettingError.InvalidSetting + } + } + + const dataToUpsert = options.setting + .filter((setting) => Object.keys(TenantSettingKeys).includes(setting.key)) + .map((s) => ({ + tenantId: options.tenantId, + ...s + })) + + if (Object.keys(dataToUpsert).length <= 0) { + return [] + } + + return TenantSetting.query(extra?.trx ?? deps.knex) + .insert(dataToUpsert) + .onConflict(['tenantId', 'key']) + .merge() + .returning('*') +} + +async function getTenantSettingPageForTenant( + deps: ServiceDependencies, + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await TenantSetting.query(deps.knex) + .whereNull('deletedAt') + .andWhere('tenantId', tenantId) + .getPage(pagination, sortOrder) +} + +async function getWalletAddressSettingsByPrefix( + deps: ServiceDependencies, + prefix: string +): Promise { + return await TenantSetting.query(deps.knex) + .whereILike('value', `${prefix}%`) + .andWhere({ + key: TenantSettingKeys.WALLET_ADDRESS_URL.name + }) +} diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index cbe82b4704..4f3dd370c3 100644 --- a/packages/backend/src/tests/app.ts +++ b/packages/backend/src/tests/app.ts @@ -28,36 +28,13 @@ export interface TestContainer { container: IocContract } -export const createTestApp = async ( - container: IocContract -): Promise => { - const config = await container.use('config') - config.adminPort = 0 - config.openPaymentsPort = 0 - config.connectorPort = 0 - config.autoPeeringServerPort = 0 - config.openPaymentsUrl = 'https://op.example' - config.walletAddressUrl = 'https://wallet.example/.well-known/pay' +export const createApolloClient = async ( + container: IocContract, + app: App, + tenantId?: string +): Promise> => { const logger = await container.use('logger') - - const app = new App(container) - await start(container, app) - - const nock = (global as unknown as { nock: typeof import('nock') }).nock - - // Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server - nock(config.openPaymentsUrl) - .get(/.*/) - .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) - .reply(200, function (path) { - return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, { - headers: this.req.headers - }).then((res) => res.data) - }) - .persist() - - const knex = await container.use('knex') - + const config = await container.use('config') const httpLink = createHttpLink({ uri: `http://localhost:${app.getAdminPort()}/graphql`, fetch @@ -79,14 +56,15 @@ export const createTestApp = async ( const authLink = setContext((_, { headers }) => { return { headers: { - ...headers + ...headers, + 'tenant-id': tenantId || config.operatorTenantId } } }) const link = ApolloLink.from([errorLink, authLink, httpLink]) - const client = new ApolloClient({ + return new ApolloClient({ cache: new InMemoryCache({}), link: link, defaultOptions: { @@ -101,6 +79,38 @@ export const createTestApp = async ( } } }) +} + +export const createTestApp = async ( + container: IocContract +): Promise => { + const config = await container.use('config') + config.adminPort = 0 + config.openPaymentsPort = 0 + config.connectorPort = 0 + config.autoPeeringServerPort = 0 + config.openPaymentsUrl = 'https://op.example' + config.walletAddressUrl = 'https://wallet.example/.well-known/pay' + + const app = new App(container) + await start(container, app) + + const nock = (global as unknown as { nock: typeof import('nock') }).nock + + // Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server + nock(config.openPaymentsUrl) + .get(/.*/) + .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) + .reply(200, function (path) { + return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, { + headers: this.req.headers + }).then((res) => res.data) + }) + .persist() + + const knex = await container.use('knex') + + const client = await createApolloClient(container, app) return { app, diff --git a/packages/backend/src/tests/asset.ts b/packages/backend/src/tests/asset.ts index daab8992be..44b6d112d8 100644 --- a/packages/backend/src/tests/asset.ts +++ b/packages/backend/src/tests/asset.ts @@ -23,12 +23,22 @@ export function randomLedger(): number { return randomInt(2 ** 16) } +interface TestAssetOptions { + assetOptions?: AssetOptions + tenantId?: string +} + export async function createAsset( deps: IocContract, - options?: AssetOptions + options?: TestAssetOptions ): Promise { + const config = await deps.use('config') const assetService = await deps.use('assetService') - const assetOrError = await assetService.create(options || randomAsset()) + const createOptions = options?.assetOptions || randomAsset() + const assetOrError = await assetService.create({ + ...createOptions, + tenantId: options?.tenantId ? options.tenantId : config.operatorTenantId + }) if (isAssetError(assetOrError)) { throw assetOrError } diff --git a/packages/backend/src/tests/combinedPayment.ts b/packages/backend/src/tests/combinedPayment.ts index 504aceeb97..5703d8cc83 100644 --- a/packages/backend/src/tests/combinedPayment.ts +++ b/packages/backend/src/tests/combinedPayment.ts @@ -22,6 +22,7 @@ export function toCombinedPayment( id: payment.id, walletAddressId: payment.walletAddressId, state: payment.state, + tenantId: payment.tenantId, metadata: payment.metadata, client: payment.client, createdAt: payment.createdAt, @@ -40,19 +41,25 @@ export async function createCombinedPayment( const sendAsset = await createAsset(deps) const receiveAsset = await createAsset(deps) const sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + assetId: sendAsset.id, + tenantId: sendAsset.tenantId + }) ).id const receiveWalletAddress = await createWalletAddress(deps, { - assetId: receiveAsset.id + assetId: receiveAsset.id, + tenantId: sendAsset.tenantId }) const type = Math.random() < 0.5 ? PaymentType.Incoming : PaymentType.Outgoing const payment = type === PaymentType.Incoming ? await createIncomingPayment(deps, { - walletAddressId: receiveWalletAddress.id + walletAddressId: receiveWalletAddress.id, + tenantId: receiveWalletAddress.tenantId }) : await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: sendWalletAddressId, method: 'ilp', receiver: `${Config.openPaymentsUrl}/${uuid()}`, diff --git a/packages/backend/src/tests/incomingPayment.ts b/packages/backend/src/tests/incomingPayment.ts index b37d93d694..618c0b41c8 100644 --- a/packages/backend/src/tests/incomingPayment.ts +++ b/packages/backend/src/tests/incomingPayment.ts @@ -10,8 +10,12 @@ export async function createIncomingPayment( deps: IocContract, options: CreateIncomingPaymentOptions ): Promise { + const config = await deps.use('config') const incomingPaymentService = await deps.use('incomingPaymentService') - const incomingPaymentOrError = await incomingPaymentService.create(options) + const incomingPaymentOrError = await incomingPaymentService.create({ + ...options, + tenantId: options.tenantId ?? config.operatorTenantId + }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw incomingPaymentOrError } diff --git a/packages/backend/src/tests/outgoingPayment.ts b/packages/backend/src/tests/outgoingPayment.ts index 6ed666b28b..c0e20eaba1 100644 --- a/packages/backend/src/tests/outgoingPayment.ts +++ b/packages/backend/src/tests/outgoingPayment.ts @@ -13,6 +13,8 @@ import { CreateIncomingPaymentOptions } from '../open_payments/payment/incoming/ import { IncomingPayment } from '../open_payments/payment/incoming/model' import { createIncomingPayment } from './incomingPayment' import assert from 'assert' +import { Config } from '../config/app' +import { OpenPaymentsPaymentMethod } from '../payment-method/provider/service' export type CreateTestQuoteAndOutgoingPaymentOptions = Omit< CreateOutgoingPaymentOptions & CreateTestQuoteOptions, @@ -24,6 +26,7 @@ export async function createOutgoingPayment( options: CreateTestQuoteAndOutgoingPaymentOptions ): Promise { const quoteOptions: CreateTestQuoteOptions = { + tenantId: options.tenantId, walletAddressId: options.walletAddressId, client: options.client, receiver: options.receiver, @@ -37,19 +40,22 @@ export async function createOutgoingPayment( const outgoingPaymentService = await deps.use('outgoingPaymentService') const config = await deps.use('config') const receiverService = await deps.use('receiverService') + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) if (options.validDestination === false) { const walletAddressService = await deps.use('walletAddressService') - const streamServer = await deps.use('streamServer') - const streamCredentials = streamServer.generateCredentials() - - const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: options.walletAddressId - }) - await incomingPayment.$query().delete() const walletAddress = await walletAddressService.get( options.walletAddressId ) assert(walletAddress) + + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: options.walletAddressId, + tenantId: walletAddress.tenantId + }) + await incomingPayment.$query().delete() + jest .spyOn(receiverService, 'get') .mockResolvedValueOnce( @@ -57,7 +63,9 @@ export async function createOutgoingPayment( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentials + await paymentMethodProviderService.getPaymentMethods( + incomingPayment + ) ), false ) @@ -87,11 +95,12 @@ interface CreateOutgoingPaymentWithReceiverArgs { quoteOptions?: Partial< Pick< CreateTestQuoteAndOutgoingPaymentOptions, - 'debitAmount' | 'receiveAmount' | 'exchangeRate' + 'debitAmount' | 'receiveAmount' | 'exchangeRate' | 'tenantId' > > sendingWalletAddress: WalletAddress fundOutgoingPayment?: boolean + receiverPaymentMethods?: OpenPaymentsPaymentMethod[] } interface CreateOutgoingPaymentWithReceiverResponse { @@ -117,23 +126,27 @@ export async function createOutgoingPaymentWithReceiver( const incomingPayment = await createIncomingPayment(deps, { ...args.incomingPaymentOptions, - walletAddressId: args.receivingWalletAddress.id + walletAddressId: args.receivingWalletAddress.id, + tenantId: Config.operatorTenantId }) const config = await deps.use('config') - const streamCredentialsService = await deps.use('streamCredentialsService') - const streamCredentials = await streamCredentialsService.get(incomingPayment) + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) const receiver = new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, args.receivingWalletAddress, - streamCredentials + args.receiverPaymentMethods || + (await paymentMethodProviderService.getPaymentMethods(incomingPayment)) ), false ) const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: args.sendingWalletAddress.tenantId, walletAddressId: args.sendingWalletAddress.id, method: args.method, receiver: receiver.incomingPayment!.id!, @@ -144,6 +157,7 @@ export async function createOutgoingPaymentWithReceiver( const outgoingPaymentService = await deps.use('outgoingPaymentService') await outgoingPaymentService.fund({ id: outgoingPayment.id, + tenantId: args.sendingWalletAddress.tenantId, amount: outgoingPayment.debitAmount.value, transferId: uuid() }) diff --git a/packages/backend/src/tests/quote.ts b/packages/backend/src/tests/quote.ts index b5d639c9ac..febc914dd9 100644 --- a/packages/backend/src/tests/quote.ts +++ b/packages/backend/src/tests/quote.ts @@ -57,6 +57,7 @@ export function mockQuote( export async function createQuote( deps: IocContract, { + tenantId, walletAddressId, receiver: receiverUrl, debitAmount, @@ -70,7 +71,10 @@ export async function createQuote( }: CreateTestQuoteOptions ): Promise { const walletAddressService = await deps.use('walletAddressService') - const walletAddress = await walletAddressService.get(walletAddressId) + const walletAddress = await walletAddressService.get( + walletAddressId, + tenantId + ) if (!walletAddress) { throw new Error('wallet not found') } @@ -174,6 +178,7 @@ export async function createQuote( const quote = await Quote.query() .insertAndFetch({ id: quoteId, + tenantId, walletAddressId, assetId: walletAddress.assetId, receiver: receiverUrl, diff --git a/packages/backend/src/tests/receiver.ts b/packages/backend/src/tests/receiver.ts index c58f218352..f391cc79e3 100644 --- a/packages/backend/src/tests/receiver.ts +++ b/packages/backend/src/tests/receiver.ts @@ -5,25 +5,37 @@ import { CreateIncomingPaymentOptions } from '../open_payments/payment/incoming/ import { WalletAddress } from '../open_payments/wallet_address/model' import { Receiver } from '../open_payments/receiver/model' import { createIncomingPayment } from './incomingPayment' +import { OpenPaymentsPaymentMethod } from '../payment-method/provider/service' + +type CreateReceiverOptions = Omit< + CreateIncomingPaymentOptions, + 'walletAddressId' +> & { + paymentMethods?: OpenPaymentsPaymentMethod[] +} export async function createReceiver( deps: IocContract, walletAddress: WalletAddress, - options?: Omit + options?: CreateReceiverOptions ): Promise { + const config = await deps.use('config') + const paymentMethodProviderService = await deps.use( + 'paymentMethodProviderService' + ) + const incomingPayment = await createIncomingPayment(deps, { ...options, - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: options?.tenantId ?? config.operatorTenantId }) - const streamCredentialsService = await deps.use('streamCredentialsService') - const config = await deps.use('config') - return new Receiver( incomingPayment.toOpenPaymentsTypeWithMethods( config.openPaymentsUrl, walletAddress, - streamCredentialsService.get(incomingPayment)! + options?.paymentMethods || + (await paymentMethodProviderService.getPaymentMethods(incomingPayment)) ), false ) diff --git a/packages/backend/src/tests/tableManager.ts b/packages/backend/src/tests/tableManager.ts index 26f07d5d2d..cf293ef36e 100644 --- a/packages/backend/src/tests/tableManager.ts +++ b/packages/backend/src/tests/tableManager.ts @@ -1,4 +1,6 @@ +import { IocContract } from '@adonisjs/fold' import { Knex } from 'knex' +import { AppServices } from '../app' export async function truncateTable( knex: Knex, @@ -9,22 +11,34 @@ export async function truncateTable( } export async function truncateTables( - knex: Knex, - ignoreTables = [ + deps: IocContract, + options?: { truncateTenants?: boolean } +): Promise { + const knex = await deps.use('knex') + const config = await deps.use('config') + const dbSchema = config.dbSchema ?? 'public' + + const truncateTenants = options?.truncateTenants ?? false + + const ignoreTables = [ 'knex_migrations', 'knex_migrations_lock', 'knex_migrations_backend', - 'knex_migrations_backend_lock' + 'knex_migrations_backend_lock', + ...(truncateTenants ? [] : ['tenants']) // So we don't delete operator tenant ] -): Promise { - const tables = await getTables(knex, ignoreTables) + const tables = await getTables(knex, dbSchema, ignoreTables) const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` await knex.raw(RAW) } -async function getTables(knex: Knex, ignoredTables: string[]): Promise { +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { const result = await knex.raw( - "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'" + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` ) return result.rows .map((val: { tablename: string }) => { diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts new file mode 100644 index 0000000000..94d907f6b4 --- /dev/null +++ b/packages/backend/src/tests/tenant.ts @@ -0,0 +1,94 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { AppServices } from '../app' +import { Tenant } from '../tenants/model' +import { + ApolloClient, + ApolloLink, + createHttpLink, + InMemoryCache, + NormalizedCacheObject +} from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { TestContainer } from './app' +import { isTenantError } from '../tenants/errors' + +interface CreateOptions { + email: string + publicName?: string + apiSecret: string + idpConsentUrl: string + idpSecret: string +} + +export function createTenantedApolloClient( + appContainer: TestContainer, + tenantId: string +): ApolloClient { + const httpLink = createHttpLink({ + uri: `http://localhost:${appContainer.app.getAdminPort()}/graphql`, + fetch + }) + const authLink = setContext((_, { headers }) => { + return { + headers: { + ...headers, + 'tenant-id': tenantId + } + } + }) + + const link = ApolloLink.from([authLink, httpLink]) + + return new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) +} + +export function generateTenantInput() { + return { + email: faker.internet.email(), + apiSecret: faker.string.alphanumeric(8), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(8), + publicName: faker.company.name() + } +} + +export async function createTenant( + deps: IocContract, + options?: CreateOptions +): Promise { + const tenantService = await deps.use('tenantService') + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenantOrError = await tenantService.create( + options || { + email: faker.internet.email(), + apiSecret: 'test-api-secret', + publicName: faker.company.name(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + ) + + if (!tenantOrError || isTenantError(tenantOrError)) { + throw Error('Failed to create test tenant') + } + + return tenantOrError +} diff --git a/packages/backend/src/tests/tenantSettings.ts b/packages/backend/src/tests/tenantSettings.ts new file mode 100644 index 0000000000..aa685b14b3 --- /dev/null +++ b/packages/backend/src/tests/tenantSettings.ts @@ -0,0 +1,34 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../app' +import { TenantSetting, TenantSettingKeys } from '../tenants/settings/model' +import { CreateOptions, KeyValuePair } from '../tenants/settings/service' +import { faker } from '@faker-js/faker' +import { isTenantSettingError } from '../tenants/settings/errors' + +export function randomSetting(): KeyValuePair { + return { + key: faker.string.alphanumeric({ + length: { min: 10, max: 20 } + }), + value: faker.string.uuid() + } +} + +export function exchangeRatesSetting(): KeyValuePair { + return { + key: TenantSettingKeys.EXCHANGE_RATES_URL.name, + value: faker.internet.url() + } +} + +export async function createTenantSettings( + deps: IocContract, + options: CreateOptions +): Promise { + const tenantSettingService = await deps.use('tenantSettingService') + const tenantSettingOrError = await tenantSettingService.create(options) + if (isTenantSettingError(tenantSettingOrError)) { + throw tenantSettingOrError + } + return tenantSettingOrError[0] +} diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index 3149d73a56..65b176705a 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -6,11 +6,14 @@ import { URL } from 'url' import { testAccessToken } from './app' import { createAsset } from './asset' +import { createTenant } from './tenant' import { AppServices } from '../app' import { isWalletAddressError } from '../open_payments/wallet_address/errors' import { WalletAddress } from '../open_payments/wallet_address/model' import { CreateOptions as BaseCreateOptions } from '../open_payments/wallet_address/service' import { LiquidityAccountType } from '../accounting/service' +import { createTenantSettings } from './tenantSettings' +import { TenantSettingKeys } from '../tenants/settings/model' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -29,10 +32,27 @@ export async function createWalletAddress( options: Partial = {} ): Promise { const walletAddressService = await deps.use('walletAddressService') + const tenantIdToUse = options.tenantId || (await createTenant(deps)).id + + const baseWalletAddressUrl = new URL( + options.address || `https://${faker.internet.domainName()}` + ) + await createTenantSettings(deps, { + tenantId: tenantIdToUse, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: baseWalletAddressUrl.origin + } + ] + }) const walletAddressOrError = (await walletAddressService.create({ ...options, - assetId: options.assetId || (await createAsset(deps)).id, - url: options.url || `https://${faker.internet.domainName()}/.well-known/pay` + assetId: + options.assetId || + (await createAsset(deps, { tenantId: tenantIdToUse })).id, + tenantId: tenantIdToUse, + address: options.address || `${baseWalletAddressUrl.origin}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) @@ -48,7 +68,7 @@ export async function createWalletAddress( ) } if (options.mockServerPort) { - const url = new URL(walletAddressOrError.url) + const url = new URL(walletAddressOrError.address) walletAddressOrError.scope = nock(url.origin) .get((uri) => uri.startsWith(url.pathname)) .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) diff --git a/packages/backend/src/tests/webhook.ts b/packages/backend/src/tests/webhook.ts index 87dddc8437..e6d0a28de8 100644 --- a/packages/backend/src/tests/webhook.ts +++ b/packages/backend/src/tests/webhook.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker' import { v4 as uuid } from 'uuid' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../app' -import { WebhookEvent } from '../webhook/model' +import { WebhookEvent } from '../webhook/event/model' import { sample } from 'lodash' import { EventPayload } from '../webhook/service' import { createAsset } from './asset' @@ -15,12 +15,14 @@ export async function createWebhookEvent( overrides?: Partial ): Promise { const knex = await deps.use('knex') + const config = await deps.use('config') const asset = await createAsset(deps) const newEvent = { id: uuid(), assetId: asset.id, type: sample(webhookEventTypes) as string, data: { field1: faker.string.sample() }, + tenantId: config.operatorTenantId, ...overrides } return await WebhookEvent.query(knex).insert(newEvent) diff --git a/packages/backend/src/webhook/event/model.ts b/packages/backend/src/webhook/event/model.ts new file mode 100644 index 0000000000..28595ee9fa --- /dev/null +++ b/packages/backend/src/webhook/event/model.ts @@ -0,0 +1,124 @@ +import { Pojo } from 'objection' + +import { BaseModel } from '../../shared/baseModel' +import { join } from 'path' +import { OutgoingPayment } from '../../open_payments/payment/outgoing/model' +import { IncomingPayment } from '../../open_payments/payment/incoming/model' +import { WalletAddress } from '../../open_payments/wallet_address/model' +import { Asset } from '../../asset/model' +import { Peer } from '../../payment-method/ilp/peer/model' +import { Webhook } from '../model' + +const fieldPrefixes = ['withdrawal'] + +export class WebhookEvent extends BaseModel { + public static get tableName(): string { + return 'webhookEvents' + } + + static relationMappings = () => ({ + webhooks: { + relation: BaseModel.HasManyRelation, + modelClass: join(__dirname, '../model'), + join: { + from: 'webhookEvents.id', + to: 'webhooks.eventId' + } + }, + outgoingPayment: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../../open_payments/payment/outgoing/model'), + join: { + from: 'webhookEvents.outgoingPaymentId', + to: 'outgoingPayments.id' + } + }, + incomingPayment: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../../open_payments/payment/incoming/model'), + join: { + from: 'webhookEvents.incomingPaymentId', + to: 'incomingPayments.id' + } + }, + walletAddress: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../../open_payments/wallet_address/model'), + join: { + from: 'webhookEvents.walletAddressId', + to: 'walletAddresses.id' + } + }, + asset: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '..'), + join: { + from: 'webhookEvents.assetId', + to: 'assets.id' + } + }, + peer: { + relation: BaseModel.BelongsToOneRelation, + modelClass: join(__dirname, '../../payment-method/ilp/peer/model'), + join: { + from: 'webhookEvents.peerId', + to: 'peer.id' + } + } + }) + + public type!: string + public data!: Record + public depositAccountId?: string + public tenantId!: string + + public readonly outgoingPaymentId?: string + public readonly incomingPaymentId?: string + public readonly walletAddressId?: string + public readonly assetId?: string + public readonly peerId?: string + + public outgoingPayment?: OutgoingPayment + public incomingPayment?: IncomingPayment + public walletAddress?: WalletAddress + public asset?: Asset + public peer?: Peer + + public webhooks?: Webhook[] + + public withdrawal?: { + accountId: string + assetId: string + amount: bigint + } + + $formatDatabaseJson(json: Pojo): Pojo { + // transforms WebhookEvent.withdrawal to db fields. eg. withdrawal.accountId => withdrawalAccountId + for (const prefix of fieldPrefixes) { + if (!json[prefix]) continue + for (const key in json[prefix]) { + json[prefix + key.charAt(0).toUpperCase() + key.slice(1)] = + json[prefix][key] + } + delete json[prefix] + } + return super.$formatDatabaseJson(json) + } + + $parseDatabaseJson(json: Pojo): Pojo { + // transforms withdrawal db fields to WebhookEvent.withdrawal. eg. withdrawalAccountId => withdrawal.accountId + json = super.$parseDatabaseJson(json) + for (const key in json) { + const prefix = fieldPrefixes.find((prefix) => key.startsWith(prefix)) + if (!prefix) continue + if (json[key] !== null) { + if (!json[prefix]) json[prefix] = {} + json[prefix][ + key.charAt(prefix.length).toLowerCase() + key.slice(prefix.length + 1) + ] = json[key] + } + delete json[key] + } + return json + } +} diff --git a/packages/backend/src/webhook/model.ts b/packages/backend/src/webhook/model.ts index 9235787025..43831675a8 100644 --- a/packages/backend/src/webhook/model.ts +++ b/packages/backend/src/webhook/model.ts @@ -1,115 +1,49 @@ -import { Pojo } from 'objection' - -import { BaseModel } from '../shared/baseModel' import { join } from 'path' -import { OutgoingPayment } from '../open_payments/payment/outgoing/model' -import { IncomingPayment } from '../open_payments/payment/incoming/model' -import { WalletAddress } from '../open_payments/wallet_address/model' -import { Asset } from '../asset/model' -import { Peer } from '../payment-method/ilp/peer/model' -const fieldPrefixes = ['withdrawal'] +import { BaseModel } from '../shared/baseModel' +import { WebhookEvent } from './event/model' +import { Tenant } from '../tenants/model' -export class WebhookEvent extends BaseModel { +export class Webhook extends BaseModel { public static get tableName(): string { - return 'webhookEvents' + return 'webhooks' } static relationMappings = () => ({ - outgoingPayment: { - relation: BaseModel.BelongsToOneRelation, - modelClass: join(__dirname, '../open_payments/payment/outgoing/model'), - join: { - from: 'webhookEvents.outgoingPaymentId', - to: 'outgoingPayments.id' - } - }, - incomingPayment: { - relation: BaseModel.BelongsToOneRelation, - modelClass: join(__dirname, '../open_payments/payment/incoming/model'), - join: { - from: 'webhookEvents.incomingPaymentId', - to: 'incomingPayments.id' - } - }, - walletAddress: { - relation: BaseModel.BelongsToOneRelation, - modelClass: join(__dirname, '../open_payments/wallet_address/model'), - join: { - from: 'webhookEvents.walletAddressId', - to: 'walletAddresses.id' - } - }, - asset: { + event: { relation: BaseModel.BelongsToOneRelation, - modelClass: join(__dirname), + modelClass: join(__dirname, './event/model'), join: { - from: 'webhookEvents.assetId', - to: 'assets.id' + from: 'webhooks.eventId', + to: 'webhookEvents.id' } }, - peer: { + tenant: { relation: BaseModel.BelongsToOneRelation, - modelClass: join(__dirname, '../payment-method/ilp/peer/model'), + modelClass: join(__dirname, '../../tenants/model'), join: { - from: 'webhookEvents.peerId', - to: 'peer.id' + from: 'webhooks.recipientTenantId', + to: 'tenants.id' } } }) - public type!: string - public data!: Record + public eventId!: string public attempts!: number public statusCode?: number public processAt!: Date | null - public depositAccountId?: string - - public readonly outgoingPaymentId?: string - public readonly incomingPaymentId?: string - public readonly walletAddressId?: string - public readonly assetId?: string - public readonly peerId?: string - - public outgoingPayment?: OutgoingPayment - public incomingPayment?: IncomingPayment - public walletAddress?: WalletAddress - public asset?: Asset - public peer?: Peer + public recipientTenantId!: string - public withdrawal?: { - accountId: string - assetId: string - amount: bigint - } + public event?: WebhookEvent + public tenant?: Tenant +} - $formatDatabaseJson(json: Pojo): Pojo { - // transforms WebhookEvent.withdrawal to db fields. eg. withdrawal.accountId => withdrawalAccountId - for (const prefix of fieldPrefixes) { - if (!json[prefix]) continue - for (const key in json[prefix]) { - json[prefix + key.charAt(0).toUpperCase() + key.slice(1)] = - json[prefix][key] - } - delete json[prefix] - } - return super.$formatDatabaseJson(json) - } +export interface WebhookWithEvent extends Webhook { + event: NonNullable +} - $parseDatabaseJson(json: Pojo): Pojo { - // transforms withdrawal db fields to WebhookEvent.withdrawal. eg. withdrawalAccountId => withdrawal.accountId - json = super.$parseDatabaseJson(json) - for (const key in json) { - const prefix = fieldPrefixes.find((prefix) => key.startsWith(prefix)) - if (!prefix) continue - if (json[key] !== null) { - if (!json[prefix]) json[prefix] = {} - json[prefix][ - key.charAt(prefix.length).toLowerCase() + key.slice(prefix.length + 1) - ] = json[key] - } - delete json[key] - } - return json - } +export function isWebhookWithEvent( + webhook: Webhook +): webhook is WebhookWithEvent { + return !!webhook.event } diff --git a/packages/backend/src/webhook/service.test.ts b/packages/backend/src/webhook/service.test.ts index dee8aee3ef..4180831686 100644 --- a/packages/backend/src/webhook/service.test.ts +++ b/packages/backend/src/webhook/service.test.ts @@ -4,18 +4,20 @@ import { URL } from 'url' import { Knex } from 'knex' import { v4 as uuid } from 'uuid' -import { WebhookEvent } from './model' +import { WebhookEvent } from './event/model' +import { Webhook } from './model' import { WebhookService, generateWebhookSignature, - RETRY_BACKOFF_MS + RETRY_BACKOFF_MS, + finalizeWebhookRecipients } from './service' import { AccountingService } from '../accounting/service' import { createTestApp, TestContainer } from '../tests/app' import { AccountFactory } from '../tests/accountFactory' import { createAsset } from '../tests/asset' import { truncateTables } from '../tests/tableManager' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' @@ -31,6 +33,9 @@ import { WalletAddressEventType } from '../open_payments/wallet_address/model' import { createOutgoingPayment } from '../tests/outgoingPayment' +import { TenantSetting, TenantSettingKeys } from '../tenants/settings/model' +import { faker } from '@faker-js/faker' +import { withConfigOverride } from '../tests/helpers' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -42,6 +47,7 @@ describe('Webhook Service', (): void => { let knex: Knex let webhookUrl: URL let event: WebhookEvent + let config: IAppConfig const WEBHOOK_SECRET = 'test secret' async function makeWithdrawalEvent(event: WebhookEvent): Promise { @@ -69,12 +75,13 @@ describe('Webhook Service', (): void => { knex = appContainer.knex webhookService = await deps.use('webhookService') accountingService = await deps.use('accountingService') + config = await deps.use('config') webhookUrl = new URL(Config.webhookUrl) }) afterEach(async (): Promise => { jest.useRealTimers() - await truncateTables(knex) + await truncateTables(deps) }) afterAll(async (): Promise => { @@ -90,7 +97,8 @@ describe('Webhook Service', (): void => { account: { id: uuid() } - } + }, + tenantId: Config.operatorTenantId }) }) @@ -110,6 +118,7 @@ describe('Webhook Service', (): void => { }) describe('Get Webhook Event by account id and types', (): void => { + let tenantId: string let walletAddressIn: WalletAddress let walletAddressOut: WalletAddress let incomingPaymentIds: string[] @@ -117,17 +126,24 @@ describe('Webhook Service', (): void => { let events: WebhookEvent[] = [] beforeEach(async (): Promise => { - walletAddressIn = await createWalletAddress(deps) - walletAddressOut = await createWalletAddress(deps) + tenantId = Config.operatorTenantId + walletAddressIn = await createWalletAddress(deps, { + tenantId + }) + walletAddressOut = await createWalletAddress(deps, { + tenantId + }) incomingPaymentIds = [ ( await createIncomingPayment(deps, { - walletAddressId: walletAddressIn.id + walletAddressId: walletAddressIn.id, + tenantId: Config.operatorTenantId }) ).id, ( await createIncomingPayment(deps, { - walletAddressId: walletAddressIn.id + walletAddressId: walletAddressIn.id, + tenantId: Config.operatorTenantId }) ).id ] @@ -135,6 +151,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false @@ -143,6 +160,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false @@ -155,25 +173,29 @@ describe('Webhook Service', (): void => { id: uuid(), type: IncomingPaymentEventType.IncomingPaymentCompleted, data: { id: uuid() }, - incomingPaymentId: incomingPaymentIds[0] + incomingPaymentId: incomingPaymentIds[0], + tenantId: Config.operatorTenantId }), await WebhookEvent.query(knex).insertAndFetch({ id: uuid(), type: IncomingPaymentEventType.IncomingPaymentExpired, data: { id: uuid() }, - incomingPaymentId: incomingPaymentIds[0] + incomingPaymentId: incomingPaymentIds[0], + tenantId: Config.operatorTenantId }), await WebhookEvent.query(knex).insertAndFetch({ id: uuid(), type: IncomingPaymentEventType.IncomingPaymentCompleted, data: { id: uuid() }, - incomingPaymentId: incomingPaymentIds[1] + incomingPaymentId: incomingPaymentIds[1], + tenantId: Config.operatorTenantId }), await WebhookEvent.query(knex).insertAndFetch({ id: uuid(), type: OutgoingPaymentEventType.PaymentCreated, data: { id: uuid() }, - outgoingPaymentId: outgoingPaymentIds[0] + outgoingPaymentId: outgoingPaymentIds[0], + tenantId: Config.operatorTenantId }) ] }) @@ -201,7 +223,8 @@ describe('Webhook Service', (): void => { id: uuid(), type: 'some_new_type', data: { id: uuid() }, - incomingPaymentId: incomingPaymentIds[0] + incomingPaymentId: incomingPaymentIds[0], + tenantId: Config.operatorTenantId }) await expect( webhookService.getLatestByResourceId({ @@ -236,6 +259,26 @@ describe('Webhook Service', (): void => { getPage: (pagination?: Pagination, sortOrder?: SortOrder) => webhookService.getPage({ pagination, sortOrder }) }) + + test('can filter by tenantId', async () => { + await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: WalletAddressEventType.WalletAddressNotFound, + data: { + account: { + id: uuid() + } + }, + tenantId: Config.operatorTenantId + }) + + await expect( + webhookService.getPage({ tenantId: Config.operatorTenantId }) + ).resolves.toHaveLength(1) + await expect( + webhookService.getPage({ tenantId: crypto.randomUUID() }) + ).resolves.toHaveLength(0) + }) }) describe('getWebhookEventsPage', (): void => { @@ -300,6 +343,7 @@ describe('Webhook Service', (): void => { }) describe('processNext', (): void => { + let webhook: Webhook beforeEach(async (): Promise => { event = await WebhookEvent.query(knex).insertAndFetch({ id: uuid(), @@ -308,44 +352,78 @@ describe('Webhook Service', (): void => { account: { id: uuid() } - } + }, + tenantId: Config.operatorTenantId }) + + webhook = await Webhook.query(knex) + .insertAndFetch({ + recipientTenantId: Config.operatorTenantId, + eventId: event.id + }) + .withGraphFetched('event') }) function mockWebhookServer( status = 200, - expectedEvent: WebhookEvent = event + expectedEvent: WebhookEvent = event, + url?: URL ): Scope { - return nock(webhookUrl.origin) - .post(webhookUrl.pathname, function (this: Definition, body) { - expect(body).toMatchObject({ - id: expectedEvent.id, - type: expectedEvent.type, - data: expectedEvent.data - }) - return true - }) + return nock(url?.origin ?? webhookUrl.origin) + .post( + url?.pathname ?? webhookUrl.pathname, + function (this: Definition, body) { + expect(body).toMatchObject({ + id: expectedEvent.id, + type: expectedEvent.type, + data: expectedEvent.data + }) + return true + } + ) .reply(status) } test('Does not process events not scheduled to be sent', async (): Promise => { - await event.$query(knex).patch({ + await webhook.$query(knex).patch({ processAt: new Date(Date.now() + 30_000) }) await expect(webhookService.getEvent(event.id)).resolves.toEqual(event) await expect(webhookService.processNext()).resolves.toBeUndefined() }) - test('Sends webhook event', async (): Promise => { - const scope = mockWebhookServer() - await expect(webhookService.processNext()).resolves.toEqual(event.id) - scope.done() - await expect(webhookService.getEvent(event.id)).resolves.toMatchObject({ - attempts: 1, - statusCode: 200, - processAt: null - }) - }) + test.each` + isTenanted | description + ${false} | ${''} + ${true} | ${' to tenant URL'} + `( + 'Sends webhook event$description', + async ({ isTenanted }): Promise => { + let setting + if (isTenanted) { + setting = await TenantSetting.query(knex).insertAndFetch({ + tenantId: Config.operatorTenantId, + key: TenantSettingKeys.WEBHOOK_URL.name, + value: faker.internet.url() + }) + } + const scope = mockWebhookServer( + 200, + event, + setting ? new URL(setting.value) : undefined + ) + await expect(webhookService.processNext()).resolves.toEqual(webhook.id) + scope.done() + const dbWebhook = await Webhook.query().findById(webhook.id) + await expect(dbWebhook).toMatchObject( + expect.objectContaining({ + attempts: 1, + statusCode: 200, + processAt: null + }) + ) + } + ) test('Signs webhook event', async (): Promise => { jest.useFakeTimers({ @@ -369,7 +447,7 @@ describe('Webhook Service', (): void => { }) .reply(200) - await expect(webhookService.processNext()).resolves.toEqual(event.id) + await expect(webhookService.processNext()).resolves.toEqual(webhook.id) scope.done() }) @@ -377,83 +455,214 @@ describe('Webhook Service', (): void => { 'Schedules retry if request fails (%i)', async (status): Promise => { const scope = mockWebhookServer(status) - await expect(webhookService.processNext()).resolves.toEqual(event.id) + await expect(webhookService.processNext()).resolves.toEqual(webhook.id) scope.done() - const updatedEvent = await webhookService.getEvent(event.id) - assert.ok(updatedEvent?.processAt) - expect(updatedEvent).toMatchObject({ + const updatedWebhook = await Webhook.query(knex).findById(webhook.id) + assert.ok(updatedWebhook?.processAt) + expect(updatedWebhook).toMatchObject({ attempts: 1, statusCode: status }) - expect(updatedEvent.processAt.getTime()).toBeGreaterThanOrEqual( + expect(updatedWebhook.processAt.getTime()).toBeGreaterThanOrEqual( event.createdAt.getTime() + RETRY_BACKOFF_MS ) } ) - test('Schedules retry if request times out', async (): Promise => { - const scope = nock(webhookUrl.origin) - .post(webhookUrl.pathname) - .delayConnection(Config.webhookTimeout + 1) - .reply(200) - await expect(webhookService.processNext()).resolves.toEqual(event.id) - scope.done() - const updatedEvent = await webhookService.getEvent(event.id) - assert.ok(updatedEvent?.processAt) - expect(updatedEvent).toMatchObject({ - attempts: 1, - statusCode: null - }) - expect(updatedEvent.processAt.getTime()).toBeGreaterThanOrEqual( - event.createdAt.getTime() + RETRY_BACKOFF_MS - ) - }) + test.each` + isTenanted | description + ${false} | ${''} + ${true} | ${' in tenant settings'} + `( + 'Schedule retry if request reaches timeout$description', + async ({ isTenanted }): Promise => { + let setting + if (isTenanted) { + setting = await TenantSetting.query(knex).insertAndFetch({ + tenantId: Config.operatorTenantId, + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: '1000' + }) + } - test('Does not send event if webhookMaxAttempts is reached', async (): Promise => { - let requests = 0 - nock(webhookUrl.origin) - .post('/') - .reply(200, () => { - requests++ + const scope = nock(webhookUrl.origin) + .post(webhookUrl.pathname) + .delay( + setting ? Number(setting.value) + 1 : Config.webhookTimeout + 1 + ) + .reply(200) + await expect(webhookService.processNext()).resolves.toEqual(webhook.id) + scope.done() + const updatedWebhook = await Webhook.query(knex).findById(webhook.id) + assert.ok(updatedWebhook?.processAt) + expect(updatedWebhook).toMatchObject({ + attempts: 1, + statusCode: null }) - await event.$query(knex).patch({ - attempts: Config.webhookMaxRetry - }) - await expect(webhookService.getEvent(event.id)).resolves.toEqual(event) - await expect(webhookService.processNext()).resolves.toBeUndefined() - expect(requests).toBe(0) - }) + expect(updatedWebhook.processAt.getTime()).toBeGreaterThanOrEqual( + event.createdAt.getTime() + RETRY_BACKOFF_MS + ) + } + ) - test('Skips the event if webhookMaxAttempts is reached (processes the next one)', async (): Promise => { - await event.$query(knex).patch({ - attempts: Config.webhookMaxRetry - }) + test.each` + isTenanted | description + ${false} | ${''} + ${true} | ${' in tenant settings'} + `( + 'Does not send event if max attempts is reached$description', + async ({ isTenanted }): Promise => { + let setting + if (isTenanted) { + setting = await TenantSetting.query(knex).insertAndFetch({ + tenantId: Config.operatorTenantId, + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '5' + }) + } + let requests = 0 + nock(webhookUrl.origin) + .post('/') + .reply(200, () => { + requests++ + }) + await webhook.$query(knex).patch({ + attempts: setting ? Number(setting.value) : Config.webhookMaxRetry + }) + await expect(webhookService.getEvent(event.id)).resolves.toEqual(event) + await expect(webhookService.processNext()).resolves.toBeUndefined() + expect(requests).toBe(0) + } + ) - const nextEvent = await WebhookEvent.query(knex).insertAndFetch({ - id: uuid(), - type: WalletAddressEventType.WalletAddressNotFound, - data: { - account: { - id: uuid() - } + test.each` + isTenanted | description + ${false} | ${''} + ${true} | ${' in tenant settings'} + `( + 'Skips the event if max attempts$description is reached (processes the next one)', + async ({ isTenanted }): Promise => { + let setting + if (isTenanted) { + setting = await TenantSetting.query(knex).insertAndFetch({ + tenantId: Config.operatorTenantId, + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: '5' + }) } - }) - const scope = mockWebhookServer(200, nextEvent) + await webhook.$query(knex).patch({ + attempts: setting ? Number(setting.value) : Config.webhookMaxRetry + }) - await expect(webhookService.getEvent(event.id)).resolves.toEqual(event) - await expect(webhookService.getEvent(nextEvent.id)).resolves.toEqual( - nextEvent - ) + const nextEvent = await WebhookEvent.query(knex).insertAndFetch({ + id: uuid(), + type: WalletAddressEventType.WalletAddressNotFound, + data: { + account: { + id: uuid() + } + }, + tenantId: Config.operatorTenantId + }) + const scope = mockWebhookServer(200, nextEvent) - await expect(webhookService.processNext()).resolves.toBe(nextEvent.id) - scope.done() + const nextWebhook = await Webhook.query(knex) + .insertAndFetch({ + recipientTenantId: Config.operatorTenantId, + eventId: nextEvent.id + }) + .withGraphFetched('event') + + await expect(webhookService.getEvent(event.id)).resolves.toEqual(event) + await expect(webhookService.getEvent(nextEvent.id)).resolves.toEqual( + nextEvent + ) + + await expect( + Webhook.query(knex).findById(webhook.id).withGraphFetched('event') + ).resolves.toEqual(webhook) + await expect( + Webhook.query(knex).findById(nextWebhook.id).withGraphFetched('event') + ).resolves.toEqual(nextWebhook) + + await expect(webhookService.processNext()).resolves.toBe(nextWebhook.id) + scope.done() + await expect( + Webhook.query(knex).findById(nextWebhook.id) + ).resolves.toMatchObject({ + attempts: 1, + statusCode: 200, + processAt: null + }) + } + ) + + test('Uses tenant webhook url', async (): Promise => { + const tenantWebhookUrl = faker.internet.url() + await TenantSetting.query(knex).insertAndFetch({ + tenantId: Config.operatorTenantId, + key: TenantSettingKeys.WEBHOOK_URL.name, + value: tenantWebhookUrl + }) + + const scope = mockWebhookServer(200, event, new URL(tenantWebhookUrl)) + await expect(webhookService.processNext()).resolves.toEqual(webhook.id) await expect( - webhookService.getEvent(nextEvent.id) + Webhook.query(knex).findById(webhook.id) ).resolves.toMatchObject({ attempts: 1, statusCode: 200, processAt: null }) + + scope.done() + }) + }) + + describe('finalizeWebhookRecipients', (): void => { + test( + 'adds operatorTenant as recipient if sendAllWebhooksToOperator is enabled', + withConfigOverride( + () => config, + { sendTenantWebhooksToOperator: true }, + async (): Promise => { + const tenantId = crypto.randomUUID() + expect(finalizeWebhookRecipients([tenantId], Config)).toStrictEqual([ + { recipientTenantId: tenantId }, + { recipientTenantId: Config.operatorTenantId } + ]) + } + ) + ) + + test( + 'does not adds operatorTenant as recipient if sendAllWebhooksToOperator is disabled', + withConfigOverride( + () => config, + { sendTenantWebhooksToOperator: false }, + async (): Promise => { + const tenantId = crypto.randomUUID() + expect(finalizeWebhookRecipients([tenantId], Config)).toStrictEqual([ + { recipientTenantId: tenantId } + ]) + } + ) + ) + + test('prevents adding duplicate recipients', async (): Promise => { + const tenantId1 = crypto.randomUUID() + const tenantId2 = crypto.randomUUID() + const tenantId3 = crypto.randomUUID() + expect( + finalizeWebhookRecipients( + [tenantId1, tenantId1, tenantId2, tenantId2, tenantId3], + Config + ) + ).toStrictEqual([ + { recipientTenantId: tenantId1 }, + { recipientTenantId: tenantId2 }, + { recipientTenantId: tenantId3 } + ]) }) }) }) diff --git a/packages/backend/src/webhook/service.ts b/packages/backend/src/webhook/service.ts index 2014ac0d20..eac17f76e4 100644 --- a/packages/backend/src/webhook/service.ts +++ b/packages/backend/src/webhook/service.ts @@ -2,12 +2,19 @@ import axios, { isAxiosError } from 'axios' import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' -import { WebhookEvent } from './model' +import { isWebhookWithEvent, Webhook, WebhookWithEvent } from './model' +import { WebhookEvent } from './event/model' import { IAppConfig } from '../config/app' import { BaseService } from '../shared/baseService' import { Pagination, SortOrder } from '../shared/baseModel' import { FilterString } from '../shared/filters' import { trace, Span } from '@opentelemetry/api' +import { + formatSettings, + FormattedTenantSettings, + TenantSettingKeys +} from '../tenants/settings/model' +import { TenantSettingService } from '../tenants/settings/service' // First retry waits 10 seconds // Second retry waits 20 (more) seconds @@ -22,6 +29,7 @@ interface GetPageOptions { pagination?: Pagination filter?: WebhookEventFilter sortOrder?: SortOrder + tenantId?: string } export interface WebhookService { @@ -35,6 +43,7 @@ export interface WebhookService { interface ServiceDependencies extends BaseService { config: IAppConfig + tenantSettingService: TenantSettingService } export async function createWebhookService( @@ -48,7 +57,7 @@ export async function createWebhookService( getEvent: (id) => getWebhookEvent(deps, id), getLatestByResourceId: (options) => getLatestWebhookEventByResourceId(deps, options), - processNext: () => processNextWebhookEvent(deps), + processNext: () => processNextWebhook(deps), getPage: (options) => getWebhookEventsPage(deps, options) } } @@ -114,9 +123,9 @@ async function getLatestWebhookEventByResourceId( return await query.first() } -// Fetch (and lock) a webhook event for work. -// Returns the id of the processed event (if any). -async function processNextWebhookEvent( +// Fetch (and lock) a webhook for work. +// Returns the id of the processed webhook (if any). +async function processNextWebhook( deps_: ServiceDependencies ): Promise { if (!deps_.knex) { @@ -125,37 +134,44 @@ async function processNextWebhookEvent( const tracer = trace.getTracer('webhook_worker') - return tracer.startActiveSpan( - 'processNextWebhookEvent', - async (span: Span) => { - return deps_.knex!.transaction(async (trx) => { - const now = Date.now() - const events = await WebhookEvent.query(trx) - .limit(1) - // Ensure the webhook event cannot be processed concurrently by multiple workers. - .forUpdate() - // If a webhook event is locked, don't wait — just come back for it later. - .skipLocked() - .where('attempts', '<', deps_.config.webhookMaxRetry) - .where('processAt', '<=', new Date(now).toISOString()) - - const event = events[0] - if (!event) return - - const deps = { - ...deps_, - knex: trx, - logger: deps_.logger.child({ - event: event.id - }) - } - - await sendWebhookEvent(deps, event) - span.end() - return event.id + return tracer.startActiveSpan('processNextWebhook', async (span: Span) => { + return deps_.knex!.transaction(async (trx) => { + const now = Date.now() + const webhooks = await Webhook.query(trx) + .limit(1) + // Ensure the webhook cannot be processed concurrently by multiple workers. + .forUpdate() + // If a webhook is locked, don't wait — just come back for it later. + .skipLocked() + .whereRaw( + `attempts < GREATEST(coalesce((select value from "tenantSettings" where "tenantId" = "webhooks"."recipientTenantId" and key = '${TenantSettingKeys.WEBHOOK_MAX_RETRY.name}')::integer, ${deps_.config.webhookMaxRetry}))` + ) + .where('processAt', '<=', new Date(now).toISOString()) + .withGraphFetched('event') + + const webhook = webhooks[0] + if (!webhook || !isWebhookWithEvent(webhook)) return + + const deps = { + ...deps_, + knex: trx, + logger: deps_.logger.child({ + event: webhook.eventId, + webhook: webhook.id + }) + } + + const settings = await deps_.tenantSettingService.get({ + tenantId: webhook.recipientTenantId }) - } - ) + const formattedSettings = formatSettings(settings) + + await sendWebhook(deps, webhook, formattedSettings) + + span.end() + return webhook.id + }) + }) } type WebhookHeaders = { @@ -163,9 +179,10 @@ type WebhookHeaders = { 'Rafiki-Signature'?: string } -async function sendWebhookEvent( +async function sendWebhook( deps: ServiceDependencies, - event: WebhookEvent + webhook: WebhookWithEvent, + settings: Partial ): Promise { try { const requestHeaders: WebhookHeaders = { @@ -173,9 +190,9 @@ async function sendWebhookEvent( } const body = { - id: event.id, - type: event.type, - data: event.data + id: webhook.event.id, + type: webhook.event.type, + data: webhook.event.data } if (deps.config.signatureSecret) { @@ -186,20 +203,22 @@ async function sendWebhookEvent( ) } - await axios.post(deps.config.webhookUrl, body, { - timeout: deps.config.webhookTimeout, + await axios.post(settings?.webhookUrl ?? deps.config.webhookUrl, body, { + timeout: settings?.webhookTimeout + ? Number(settings?.webhookTimeout) + : deps.config.webhookTimeout, headers: requestHeaders, validateStatus: (status) => status === 200 }) - await event.$query(deps.knex).patch({ - attempts: event.attempts + 1, + await webhook.$query(deps.knex).patch({ + attempts: webhook.attempts + 1, statusCode: 200, processAt: null }) } catch (err) { if (isAxiosError(err)) { - const attempts = event.attempts + 1 + const attempts = webhook.attempts + 1 const errorMessage = err.message deps.logger.warn( { @@ -209,7 +228,7 @@ async function sendWebhookEvent( 'webhook request failed' ) - await event.$query(deps.knex).patch({ + await webhook.$query(deps.knex).patch({ attempts, statusCode: err.response ? err.response.status : undefined, processAt: new Date( @@ -248,13 +267,35 @@ async function getWebhookEventsPage( deps: ServiceDependencies, options?: GetPageOptions ): Promise { - const { filter, pagination, sortOrder } = options ?? {} + const { filter, pagination, sortOrder, tenantId } = options ?? {} const query = WebhookEvent.query(deps.knex) + if (tenantId) { + query.where('tenantId', tenantId) + } + if (filter?.type?.in && filter.type.in.length > 0) { query.whereIn('type', filter.type.in) } return await query.getPage(pagination, sortOrder) } + +export function finalizeWebhookRecipients( + tenantIds: string[], + config: IAppConfig +): Pick[] { + const tenantIdSet = new Set(tenantIds) + + if ( + !tenantIdSet.has(config.operatorTenantId) && + config.sendTenantWebhooksToOperator + ) { + tenantIdSet.add(config.operatorTenantId) + } + + return [...tenantIdSet.values()].map((tenantId) => ({ + recipientTenantId: tenantId + })) +} diff --git a/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx b/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx index 570c54e902..57b5cd2642 100644 --- a/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx +++ b/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx @@ -364,6 +364,7 @@ services: AUTH_PORT: 3006 INTROSPECTION_PORT: 3007 INTERACTION_PORT: 3009 + SERVICE_API_PORT: 3011 COOKIE_KEY: {...} IDENTITY_SERVER_SECRET: {...} IDENTITY_SERVER_URL: {https://idp.mysystem.com} @@ -378,6 +379,7 @@ services: - '3006:3006' - '3007:3007' - '3009:3009' + - '3011:3011' restart: always rafiki-backend: @@ -387,6 +389,7 @@ depends_on: - postgres - redis environment: AUTH_SERVER_GRANT_URL: {https://auth.myrafiki.com} AUTH_SERVER_INTROSPECTION_URL: {https://auth.myrafiki.com/3007} +AUTH_SERVICE_API_URL: {https://auth.myrafiki.com/3011} DATABASE_URL: {postgresql://...} ILP_ADDRESS: {test.myrafiki} ADMIN_PORT: 3001 diff --git a/packages/documentation/src/partials/auth-variables.mdx b/packages/documentation/src/partials/auth-variables.mdx index 64d434575e..8803b4a59a 100644 --- a/packages/documentation/src/partials/auth-variables.mdx +++ b/packages/documentation/src/partials/auth-variables.mdx @@ -43,6 +43,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `INTERACTION_EXPIRY_SECONDS` | `auth.interactionExpirySeconds` | `600` (10 minutes) | The time, in seconds, for which a user can interact with a grant request before the request expires. | | `INTERACTION_PORT` | `auth.port.interaction` | `3009` | The port number of your Open Payments interaction-related APIs. | | `INTROSPECTION_PORT` | `auth.port.introspection` | `3007` | The port of your Open Payments access token introspection server. | +| `SERVICE_API_PORT` | `auth.port.serviceAPIPort` | `3011` | The port to expose the internal service api. | | `LIST_ALL_ACCESS_INTERACTION` | `auth.interaction.listAll` | `true` | When `true`, grant requests that include a `list-all` action will require interaction. In these requests, the client asks to list resources that it did not create. | | `LOG_LEVEL` | `auth.logLevel` | `info` | Pino log level | | `NODE_ENV` | `auth.nodeEnv` | `development` | The type of node environment: `development`, `test`, or `production`. | diff --git a/packages/documentation/src/partials/backend-variables.mdx b/packages/documentation/src/partials/backend-variables.mdx index 20fc3017cf..29bdd4a86c 100644 --- a/packages/documentation/src/partials/backend-variables.mdx +++ b/packages/documentation/src/partials/backend-variables.mdx @@ -17,6 +17,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `REDIS_URL` | `backend.redis.host`,
`backend.redis.port` | `redis://127.0.0.1:6379` | The Redis URL of the database handling ILP packet data. For Helm, these components are provided individually. | | `USE_TIGERBEETLE` | `backend.use.tigerbeetle` | `true` | When `true`, a TigerBeetle database is used for accounting. When `false`, a Postgres database is used. | | `WEBHOOK_URL` | `backend.serviceUrls.WEBHOOK_URL` | _undefined_ | Your endpoint that consumes webhook events. | +| `AUTH_SERVICE_API_URL` | `backend.serviceUrls.AUTH_SERVICE_API_URL` | _undefined_ | The service-to-service api endpoint on your Open Payments authorization server. | diff --git a/packages/frontend/app/components/ApiCredentialsForm.tsx b/packages/frontend/app/components/ApiCredentialsForm.tsx new file mode 100644 index 0000000000..9f42409025 --- /dev/null +++ b/packages/frontend/app/components/ApiCredentialsForm.tsx @@ -0,0 +1,113 @@ +import { Form, useActionData, useNavigation } from '@remix-run/react' +import { useRef, useState, useEffect } from 'react' +import { Input, Button } from '~/components/ui' +import { validate as validateUUID } from 'uuid' + +interface ApiCredentialsFormProps { + showClearCredentials: boolean + defaultTenantId: string + defaultApiSecret: string +} + +interface ActionErrorResponse { + status: number + statusText: string +} + +export const ApiCredentialsForm = ({ + showClearCredentials, + defaultTenantId, + defaultApiSecret +}: ApiCredentialsFormProps) => { + const actionData = useActionData() + const navigation = useNavigation() + const inputRef = useRef(null) + const formRef = useRef(null) + const [tenantIdError, setTenantIdError] = useState(null) + + const isSubmitting = navigation.state === 'submitting' + + const handleTenantIdChange = (event: React.ChangeEvent) => { + const tenantId = event.target.value.trim() + + if (tenantId === '') { + setTenantIdError('Tenant ID is required') + } else if (!validateUUID(tenantId)) { + setTenantIdError('Invalid Tenant ID (must be a valid UUID)') + } else { + setTenantIdError(null) + } + } + + // auto submit form if values passed in + useEffect(() => { + if (defaultTenantId && defaultApiSecret && !tenantIdError) { + if (formRef.current) { + formRef.current.submit() + } + } + }, [defaultTenantId, defaultApiSecret, tenantIdError]) + + return ( +
+ {showClearCredentials ? ( +
+

✓ API credentials configured

+ + +
+ ) : ( +
+ + {tenantIdError && ( +

+ {tenantIdError} +

+ )} + + +
+ +
+
+ )} + {actionData?.statusText && ( +
{actionData.statusText}
+ )} +
+ ) +} diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 5b140a25ca..5e37ef29d5 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { Button } from '~/components/ui' interface SidebarProps { logoutUrl: string authEnabled: boolean + hasApiCredentials: boolean } const navigation = [ @@ -16,6 +17,10 @@ const navigation = [ name: 'Home', href: '/' }, + { + name: 'Tenants', + href: '/tenants' + }, { name: 'Assets', href: '/assets' @@ -38,9 +43,17 @@ const navigation = [ } ] -export const Sidebar: FC = ({ logoutUrl, authEnabled }) => { +export const Sidebar: FC = ({ + logoutUrl, + authEnabled, + hasApiCredentials +}) => { const [sidebarIsOpen, setSidebarIsOpen] = useState(false) + const navigationToShow = hasApiCredentials + ? navigation + : navigation.filter(({ name }) => name === 'Home') + return ( <> @@ -81,7 +94,7 @@ export const Sidebar: FC = ({ logoutUrl, authEnabled }) => {