diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru index c078d15371..1782f0533f 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru @@ -19,6 +19,7 @@ body:graphql { apiSecret idpConsentUrl idpSecret + walletAddressPrefix } } } @@ -30,7 +31,8 @@ body:graphql:vars { "email": "example@example.com", "apiSecret": "test-secret", "idpConsentUrl": "https://example.com/consent", - "idpSecret": "test-idp-secret" + "idpSecret": "test-idp-secret", + "walletAddressPrefix": "https://cloud-nine-wallet-backend" } } } diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 7d20ea747b..416d0b0cf6 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 99b6ceccad..fabc9b0748 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -1,7 +1,7 @@ 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.OPEN_PAYMENTS_URL = 'https://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' diff --git a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js new file mode 100644 index 0000000000..9365191bbb --- /dev/null +++ b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js @@ -0,0 +1,51 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('tenants', (table) => { + table.string('walletAddressPrefix').unique() + }) + .then(() => { + return knex.raw( + `UPDATE "tenants" SET "walletAddressPrefix" = (SELECT "value" from "tenantSettings" WHERE "tenantId" = "tenants"."id" AND "key" = 'WALLET_ADDRESS_URL')` + ) + }) + .then(() => { + return knex.raw( + `DELETE FROM "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'` + ) + }) + .then(() => { + return knex.raw( + `UPDATE "tenants" SET "walletAddressPrefix" = '${process.env.OPEN_PAYMENTS_URL}' WHERE "id" = '${process.env.OPERATOR_TENANT_ID}'` + ) + }) + .then(() => { + return knex.raw( + `UPDATE "tenants" SET "walletAddressPrefix" = '${process.env.OPEN_PAYMENTS_URL}/' || gen_random_uuid() WHERE "walletAddressPrefix" IS NULL` + ) + }) + .then(() => { + return knex.schema.alterTable('tenants', (table) => { + table.dropNullable('walletAddressPrefix') + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex + .raw( + `INSERT INTO "tenantSettings" ("id", "key", "value", "tenantId") SELECT gen_random_uuid(), 'WALLET_ADDRESS_URL', "walletAddressPrefix", "id" FROM "tenants" WHERE "walletAddressPrefix" IS NOT NULL` + ) + .then(() => { + return knex.schema.alterTable('tenants', (table) => { + table.dropColumn('walletAddressPrefix') + }) + }) +} diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index c7b6008c03..dd429b869d 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -26,8 +26,7 @@ import { TenantSettingService } from '../tenants/settings/service' import { exchangeRatesSetting } from '../tests/tenantSettings' -import { createTenantSettings } from '../tests/tenantSettings' -import { TenantSettingKeys } from '../tenants/settings/model' +import { TenantService } from '../tenants/service' describe('Asset Service', (): void => { let deps: IocContract @@ -35,6 +34,7 @@ describe('Asset Service', (): void => { let assetService: AssetService let peerService: PeerService let walletAddressService: WalletAddressService + let tenantService: TenantService let tenantSettingService: TenantSettingService let config: IAppConfig @@ -44,6 +44,7 @@ describe('Asset Service', (): void => { config = await deps.use('config') assetService = await deps.use('assetService') walletAddressService = await deps.use('walletAddressService') + tenantService = await deps.use('tenantService') tenantSettingService = await deps.use('tenantSettingService') peerService = await deps.use('peerService') }) @@ -74,14 +75,9 @@ describe('Asset Service', (): void => { }) beforeEach(async () => { - await createTenantSettings(deps, { - tenantId: Config.operatorTenantId, - setting: [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: 'https://alice.me' - } - ] + await tenantService.update({ + id: Config.operatorTenantId, + walletAddressPrefix: 'https://alice.me' }) }) diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 49443b5177..0a99432d61 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -2398,6 +2398,22 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "walletAddressPrefix", + "description": "Prefix for all wallet addresses belonging to this tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, @@ -8787,6 +8803,22 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "walletAddressPrefix", + "description": "Prefix for wallet addresses belonging to this tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -8981,12 +9013,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "WALLET_ADDRESS_URL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "WEBHOOK_MAX_RETRY", "description": null, @@ -9520,6 +9546,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "walletAddressPrefix", + "description": "Prefix for all wallet addresses belonging to this tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "interfaces": null, diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 7d20ea747b..416d0b0cf6 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/src/graphql/resolvers/tenant.test.ts b/packages/backend/src/graphql/resolvers/tenant.test.ts index d291cfdbb3..400ffc644c 100644 --- a/packages/backend/src/graphql/resolvers/tenant.test.ts +++ b/packages/backend/src/graphql/resolvers/tenant.test.ts @@ -241,6 +241,7 @@ describe('Tenant Resolvers', (): void => { idpConsentUrl idpSecret publicName + walletAddressPrefix } } } @@ -274,6 +275,7 @@ describe('Tenant Resolvers', (): void => { idpConsentUrl idpSecret publicName + walletAddressPrefix } } } @@ -406,6 +408,7 @@ describe('Tenant Resolvers', (): void => { idpConsentUrl idpSecret publicName + walletAddressPrefix } } } @@ -441,6 +444,7 @@ describe('Tenant Resolvers', (): void => { idpConsentUrl idpSecret publicName + walletAddressPrefix } } } diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts index ce1505f945..0f9de66e3f 100644 --- a/packages/backend/src/graphql/resolvers/tenant.ts +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -144,9 +144,13 @@ export const updateTenant: MutationResolvers['updateTenan const tenantService = await ctx.container.use('tenantService') try { - const updatedTenant = await tenantService.update(args.input) - return { tenant: tenantToGraphQl(updatedTenant) } + const updatedTenantOrError = await tenantService.update(args.input) + if (isTenantError(updatedTenantOrError)) { + throw updatedTenantOrError + } + return { tenant: tenantToGraphQl(updatedTenantOrError) } } catch (err) { + if (isTenantError(err)) throw err throw new GraphQLError('failed to update tenant', { extensions: { code: GraphQLErrorCode.NotFound @@ -191,6 +195,7 @@ export function tenantToGraphQl(tenant: Tenant): SchemaTenant { idpConsentUrl: tenant.idpConsentUrl, idpSecret: tenant.idpSecret, publicName: tenant.publicName, + walletAddressPrefix: tenant.walletAddressPrefix, settings: tenantSettingsToGraphql(tenant.settings), createdAt: new Date(+tenant.createdAt).toISOString(), deletedAt: tenant.deletedAt diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts index e89a3b3930..2b469f3235 100644 --- a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts +++ b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts @@ -27,7 +27,6 @@ import { errorToMessage, TenantSettingError } from '../../tenants/settings/errors' -import { createTenantSettings } from '../../tests/tenantSettings' function createTenantedApolloClient( appContainer: TestContainer, @@ -184,71 +183,6 @@ describe('Tenant Settings Resolvers', (): void => { ) } }) - - test('errors if existing wallet address url is provided', async (): Promise => { - const walletAddressUrl = faker.internet.url() - const existingTenant = await createTenant(deps) - await createTenantSettings(deps, { - tenantId: existingTenant.id, - setting: [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: walletAddressUrl - } - ] - }) - - const newTenant = await createTenant(deps) - const client = await createTenantedApolloClient( - appContainer, - newTenant.id - ) - expect.assertions(2) - try { - await client - .mutate({ - mutation: gql` - mutation CreateTenantSettings( - $input: CreateTenantSettingsInput! - ) { - createTenantSettings(input: $input) { - settings { - key - value - } - } - } - `, - variables: { - input: { - settings: [ - { - key: SchemaTenantSettingKey.WalletAddressUrl, - value: walletAddressUrl - } - ] - } - } - }) - .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.DuplicateWalletAddressUrl], - extensions: expect.objectContaining({ - code: errorToCode[TenantSettingError.DuplicateWalletAddressUrl] - }) - }) - ) - } - }) }) describe('Get Tenant Settings', (): void => { diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.ts b/packages/backend/src/graphql/resolvers/tenant_settings.ts index f4ffcc4fb7..fa5ed77401 100644 --- a/packages/backend/src/graphql/resolvers/tenant_settings.ts +++ b/packages/backend/src/graphql/resolvers/tenant_settings.ts @@ -65,8 +65,6 @@ const tenantSettingNameToGraphQl: { [key: string]: SchemaTenantSettingKey } = { SchemaTenantSettingKey.WebhookTimeout, [TenantSettingKeys.WEBHOOK_MAX_RETRY.name]: SchemaTenantSettingKey.WebhookMaxRetry, - [TenantSettingKeys.WALLET_ADDRESS_URL.name]: - SchemaTenantSettingKey.WalletAddressUrl, [TenantSettingKeys.ILP_ADDRESS.name]: SchemaTenantSettingKey.IlpAddress } diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index 88c5144acd..bc525c2a4c 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -47,9 +47,9 @@ 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' +import { TenantService } from '../../tenants/service' +import { isTenantError } from '../../tenants/errors' describe('Wallet Address Resolvers', (): void => { let deps: IocContract @@ -57,6 +57,8 @@ describe('Wallet Address Resolvers', (): void => { let knex: Knex let walletAddressService: WalletAddressService let assetService: AssetService + let tenantService: TenantService + let prefix: string beforeAll(async (): Promise => { deps = initIocContainer({ @@ -67,6 +69,14 @@ describe('Wallet Address Resolvers', (): void => { knex = appContainer.knex walletAddressService = await deps.use('walletAddressService') assetService = await deps.use('assetService') + tenantService = await deps.use('tenantService') + const tenant = await tenantService.update({ + id: Config.operatorTenantId, + walletAddressPrefix: 'https://alice.me' + }) + assert.ok(!isTenantError(tenant)) + assert.ok(tenant.walletAddressPrefix) + prefix = tenant.walletAddressPrefix }) afterEach(async (): Promise => { @@ -78,17 +88,7 @@ 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' - } - ] - }) - }) + beforeEach(async () => {}) describe('Create Wallet Address', (): void => { let asset: Asset @@ -99,7 +99,7 @@ describe('Wallet Address Resolvers', (): void => { input = { assetId: asset.id, tenantId: Config.operatorTenantId, - address: 'https://alice.me/.well-known/pay' + address: `${prefix}/.well-known/pay` } }) @@ -396,7 +396,9 @@ describe('Wallet Address Resolvers', (): void => { 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 nonOperatorTenant = await createTenant(deps, { + walletAddressPrefix: 'https://bob.me' + }) const asset = await createAsset(deps, { assetOptions: { code: 'xyz', @@ -404,15 +406,6 @@ describe('Wallet Address Resolvers', (): void => { }, 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, @@ -806,7 +799,8 @@ describe('Wallet Address Resolvers', (): void => { publicName: 'test tenant new', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret-new' + idpSecret: 'test-idp-secret-new', + walletAddressPrefix: 'https://charlie.me' } const newTenant = await Tenant.query(knex).insertAndFetch(tenantOptions) const newAsset = await assetService.create({ @@ -815,20 +809,10 @@ describe('Wallet Address Resolvers', (): void => { 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' + address: 'https://charlie.me/.well-known/pay-2' }) const id = (newWalletAddress as WalletAddressModel).id diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 832feb2e60..ea04b0ccf2 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1641,6 +1641,8 @@ type Tenant implements Model { idpSecret: String "Public name for the tenant." publicName: String + "Prefix for wallet addresses belonging to this tenant." + walletAddressPrefix: String! "The date and time that this tenant was created." createdAt: String! "The date and time that this tenant was deleted." @@ -1668,7 +1670,6 @@ enum TenantSettingKey { WEBHOOK_URL WEBHOOK_TIMEOUT WEBHOOK_MAX_RETRY - WALLET_ADDRESS_URL ILP_ADDRESS } @@ -1692,6 +1693,8 @@ input CreateTenantInput { idpSecret: String "Public name for the tenant." publicName: String + "Prefix for all wallet addresses belonging to this tenant." + walletAddressPrefix: String! "Initial settings for tenant." settings: [TenantSettingInput!] } @@ -1709,6 +1712,8 @@ input UpdateTenantInput { idpSecret: String "Public name for the tenant." publicName: String + "Prefix for all wallet addresses belonging to this tenant." + walletAddressPrefix: String } type TenantMutationResponse { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 57b8ba759b..7ad3ffa6ff 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -360,6 +360,7 @@ export function initIocContainer( webhookService: await deps.use('webhookService'), assetService: await deps.use('assetService'), walletAddressCache: await deps.use('walletAddressCache'), + tenantService: await deps.use('tenantService'), tenantSettingService: await deps.use('tenantSettingService') }) }) diff --git a/packages/backend/src/open_payments/wallet_address/errors.ts b/packages/backend/src/open_payments/wallet_address/errors.ts index 6d0f2151cd..ad548e0cd6 100644 --- a/packages/backend/src/open_payments/wallet_address/errors.ts +++ b/packages/backend/src/open_payments/wallet_address/errors.ts @@ -5,7 +5,7 @@ export enum WalletAddressError { UnknownAsset = 'UnknownAsset', UnknownWalletAddress = 'UnknownWalletAddress', DuplicateWalletAddress = 'DuplicateWalletAddress', - WalletAddressSettingNotFound = 'WalletAddressSettingNotFound' + UnknownTenant = 'UnknownTenant' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -19,7 +19,7 @@ export const errorToCode: { [WalletAddressError.UnknownAsset]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownWalletAddress]: GraphQLErrorCode.NotFound, [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate, - [WalletAddressError.WalletAddressSettingNotFound]: GraphQLErrorCode.NotFound + [WalletAddressError.UnknownTenant]: GraphQLErrorCode.BadUserInput } export const errorToMessage: { @@ -30,6 +30,5 @@ export const errorToMessage: { [WalletAddressError.UnknownWalletAddress]: 'unknown wallet address', [WalletAddressError.DuplicateWalletAddress]: 'Duplicate wallet address found with the same url', - [WalletAddressError.WalletAddressSettingNotFound]: - 'Setting for wallet address has not been found.' + [WalletAddressError.UnknownTenant]: 'unknown tenant' } 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 95c1860fb5..813dbd7190 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -27,8 +27,6 @@ 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' import { IncomingPaymentInitiationReason } from '../payment/incoming/types' describe('Open Payments Wallet Address Service', (): void => { @@ -61,25 +59,17 @@ describe('Open Payments Wallet Address Service', (): void => { }) describe('Create or Get Wallet Address', (): void => { + let prefix: string let tenantId: string let options: CreateOptions beforeEach(async (): Promise => { - tenantId = (await createTenant(deps)).id + prefix = `https://alice.me/${uuid()}` + tenantId = (await createTenant(deps, { walletAddressPrefix: prefix })).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 = { - address: 'https://alice.me/.well-known/pay', + address: `${prefix}/.well-known/pay`, assetId, tenantId } @@ -104,64 +94,13 @@ describe('Open Payments Wallet Address Service', (): void => { } ) - test.each` - isOperator | tenantSettingUrl - ${false} | ${undefined} - ${true} | ${undefined} - ${true} | ${`https://alice.me/${uuid()}`} - `( - '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, - address, - isOperator, - assetId: tempAssetId, - tenantId: tempTenant.id - }) - - 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) - + test('should return error if unknown tenant', async () => { expect( await walletAddressService.create({ ...options, - tenantId: tempTenant.id + tenantId: uuid() }) - ).toEqual(WalletAddressError.WalletAddressSettingNotFound) + ).toEqual(WalletAddressError.UnknownTenant) }) test('should return InvalidUrl error if wallet address URL does not start with tenant wallet address URL', async (): Promise => { @@ -173,21 +112,23 @@ describe('Open Payments Wallet Address Service', (): void => { }) test.each` - 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'} + prefix | address | generated + ${'https://alice.me/ilp/1'} | ${'https://alice.me/ilp/1/test'} | ${'https://alice.me/ilp/1/test'} + ${'https://alice.me/ilp/2'} | ${'test'} | ${'https://alice.me/ilp/2/test'} + ${'https://alice.me/ilp/3'} | ${'/test'} | ${'https://alice.me/ilp/3/test'} + ${'https://alice.me/ilp/4/'} | ${'test'} | ${'https://alice.me/ilp/4/test'} + ${'https://alice.me/ilp/5'} | ${'/test'} | ${'https://alice.me/ilp/5/test'} `( - '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 } - ] + 'should create address $generated with address $address and prefix $prefix', + async ({ prefix, address, generated }): Promise => { + const tenant = await createTenant(deps, { + walletAddressPrefix: prefix }) + const asset = await createAsset(deps, { tenantId: tenant.id }) + const options = { + tenantId: tenant.id, + assetId: asset.id + } const walletAddress = await walletAddressService.create({ ...options, @@ -236,7 +177,7 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('Creating wallet address with case insensitiveness', async (): Promise => { - const address = 'https://Alice.me/pay' + const address = `${prefix.replace('a', 'A')}/pay` await expect( walletAddressService.create({ ...options, @@ -246,7 +187,7 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('Wallet address cannot be created if the url is duplicated', async (): Promise => { - const address = 'https://Alice.me/pay' + const address = `${prefix}/pay` const wallet = walletAddressService.create({ ...options, address @@ -616,15 +557,8 @@ describe('Open Payments Wallet Address Service', (): void => { { 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()}` - } - ] + const tenant = await createTenant(deps, { + walletAddressPrefix: `${walletAddressUrl}/${uuid()}` }) await expect( @@ -661,7 +595,11 @@ describe('Open Payments Wallet Address Service', (): void => { () => config, { walletAddressPollingFrequencyMs: 10 }, async (): Promise => { - const walletAddressUrl = `https://${faker.internet.domainName()}/.well-known/pay` + const prefix = `https://${faker.internet.domainName()}` + const tenant = await createTenant(deps, { + walletAddressPrefix: prefix + }) + const walletAddressUrl = `${prefix}/.well-known/pay` const [getOrPollByUrlWalletAddress, createdWalletAddress] = await Promise.all([ @@ -669,7 +607,7 @@ describe('Open Payments Wallet Address Service', (): void => { (async () => { await sleep(5) return createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId: tenant.id, address: walletAddressUrl }) })() diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index b0a8aff516..1ad69a77b8 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -31,8 +31,8 @@ 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' +import { TenantService } from '../../tenants/service' interface Options { publicName?: string @@ -90,6 +90,7 @@ interface ServiceDependencies extends BaseService { webhookService: WebhookService assetService: AssetService walletAddressCache: CacheDataStore + tenantService: TenantService tenantSettingService: TenantSettingService } @@ -101,6 +102,7 @@ export async function createWalletAddressService({ webhookService, assetService, walletAddressCache, + tenantService, tenantSettingService }: ServiceDependencies): Promise { const log = logger.child({ @@ -114,6 +116,7 @@ export async function createWalletAddressService({ webhookService, assetService, walletAddressCache, + tenantService, tenantSettingService } return { @@ -174,20 +177,10 @@ async function createWalletAddressUrl( deps: ServiceDependencies, options: CreateOptions ): Promise { - let tenantWalletAddressUrl = new URL(deps.config.openPaymentsUrl) + const tenant = await deps.tenantService.get(options.tenantId) - 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) - } + if (!tenant) return WalletAddressError.UnknownTenant + const tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) let tenantBaseUrl = tenantWalletAddressUrl.toString() if (!tenantWalletAddressUrl.pathname.endsWith('/')) { @@ -383,8 +376,8 @@ async function getOrPollByUrl( if (existingWalletAddress) return existingWalletAddress const webhookRecipients = ( - await deps.tenantSettingService.getSettingsByPrefix(url) - ).map((tenantSetting) => tenantSetting.tenantId) + await deps.tenantService.getTenantsByPrefix(url) + ).map((tenant) => tenant.id) if (!webhookRecipients.length) { webhookRecipients.push(deps.config.operatorTenantId) diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index e4b85624f2..ec10c4bd30 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -302,7 +302,8 @@ describe('utils', (): void => { publicName: faker.company.name(), apiSecret: crypto.randomBytes(8).toString('base64'), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() }) operator = await Tenant.query(appContainer.knex).insertAndFetch({ @@ -310,7 +311,8 @@ describe('utils', (): void => { publicName: faker.company.name(), apiSecret: operatorApiSecret, idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() }) }) diff --git a/packages/backend/src/tenants/errors.ts b/packages/backend/src/tenants/errors.ts index 5bfba012df..70a609d609 100644 --- a/packages/backend/src/tenants/errors.ts +++ b/packages/backend/src/tenants/errors.ts @@ -1,6 +1,8 @@ export enum TenantError { TenantNotFound = 'TenantNotFound', - InvalidTenantId = 'InvalidTenantId' + InvalidTenantId = 'InvalidTenantId', + InvalidTenantInput = 'InvalidTenantInput', + DuplicateWalletAddressPrefix = 'DuplicateWalletAddressPrefix' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -11,5 +13,7 @@ export const errorToMessage: { [key in TenantError]: string } = { [TenantError.TenantNotFound]: 'Tenant not found', - [TenantError.InvalidTenantId]: 'Invalid Tenant ID' + [TenantError.InvalidTenantId]: 'Invalid Tenant ID', + [TenantError.InvalidTenantInput]: 'Invalid Tenant input', + [TenantError.DuplicateWalletAddressPrefix]: 'Duplicate Wallet Address prefix' } diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index 81503dfcb1..2b35654f37 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -24,6 +24,7 @@ export class Tenant extends BaseModel { public apiSecret!: string public idpConsentUrl!: string public idpSecret!: string + public walletAddressPrefix!: string public publicName?: string public settings?: TenantSetting[] diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index d499510ba2..4886b6bfe5 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -69,7 +69,8 @@ describe('Tenant Service', (): void => { publicName: 'test tenant', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } const createdTenant = @@ -86,7 +87,8 @@ describe('Tenant Service', (): void => { email: faker.internet.email(), idpConsentUrl: faker.internet.url(), idpSecret: 'test-idp-secret', - deletedAt: new Date() + deletedAt: new Date(), + walletAddressPrefix: faker.internet.url() }) const tenant = await tenantService.get(dbTenant.id) @@ -103,7 +105,8 @@ describe('Tenant Service', (): void => { email: faker.internet.email(), idpConsentUrl: faker.internet.url(), idpSecret: 'test-idp-secret', - deletedAt: new Date() + deletedAt: new Date(), + walletAddressPrefix: faker.internet.url() }) const tenant = await tenantService.get(dbTenant.id) @@ -113,6 +116,33 @@ describe('Tenant Service', (): void => { const tenantDel = await tenantService.get(dbTenant.id, true) expect(tenantDel?.deletedAt).toBeDefined() }) + + test('can get tenants by wallet address prefix', async (): Promise => { + const baseUrl = `https://${faker.internet.domainName()}` + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + await Promise.all([ + createTenant(deps, { walletAddressPrefix: `${baseUrl}/${v4()}` }), + createTenant(deps, { walletAddressPrefix: `${baseUrl}/${v4()}` }) + ]) + + const retrievedTenants = await tenantService.getTenantsByPrefix(baseUrl) + expect(retrievedTenants).toHaveLength(2) + }) + + test('does not retrieve tenants if no prefix matches', async (): Promise => { + const baseUrl = `https://${faker.internet.domainName()}` + await Promise.all([ + createTenant(deps, { walletAddressPrefix: `${baseUrl}/${v4()}` }), + createTenant(deps, { walletAddressPrefix: `${baseUrl}/${v4()}` }) + ]) + + const retrievedTenants = await tenantService.getTenantsByPrefix( + faker.internet.url() + ) + expect(retrievedTenants).toHaveLength(0) + }) }) describe('create', (): void => { @@ -122,7 +152,8 @@ describe('Tenant Service', (): void => { publicName: 'test tenant', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: `${config.openPaymentsUrl}/${v4()}` } const spy = jest @@ -149,17 +180,18 @@ describe('Tenant Service', (): void => { }) test('can create a tenant with a setting', async () => { - const walletAddressUrl = 'https://example.com' + const webhookUrl = 'https://example.com' const createOptions = { apiSecret: 'test-api-secret', publicName: 'test tenant', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url(), settings: [ { - key: SchemaTenantSettingKey.WalletAddressUrl, - value: walletAddressUrl + key: SchemaTenantSettingKey.WebhookUrl, + value: webhookUrl } ] } @@ -172,10 +204,10 @@ describe('Tenant Service', (): void => { assert(!isTenantError(tenant)) const tenantSetting = await TenantSetting.query() .where('tenantId', tenant.id) - .andWhere('key', SchemaTenantSettingKey.WalletAddressUrl) + .andWhere('key', SchemaTenantSettingKey.WebhookUrl) expect(tenantSetting.length).toBe(1) - expect(tenantSetting[0].value).toEqual(walletAddressUrl) + expect(tenantSetting[0].value).toEqual(webhookUrl) }) test('can create tenant with a specified id', async (): Promise => { @@ -186,7 +218,8 @@ describe('Tenant Service', (): void => { publicName: 'test tenant', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest @@ -204,7 +237,8 @@ describe('Tenant Service', (): void => { publicName: 'test tenant', email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } const spy = jest @@ -232,6 +266,48 @@ describe('Tenant Service', (): void => { ) } }) + + test('cannot create tenant with invalid url for wallet address prefix', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + walletAddressPrefix: `invalid-url-prefix` + } + + const tenantError = await tenantService.create(createOptions) + assert(isTenantError(tenantError)) + expect(tenantError).toEqual(TenantError.InvalidTenantInput) + }) + + test('cannot create tenant with duplicate wallet address prefix', async (): Promise => { + const walletAddressPrefix = `${config.openPaymentsUrl}/something}` + const createOptions1 = { + apiSecret: 'test-api-secret', + publicName: 'test tenant1', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret1', + walletAddressPrefix + } + const createOptions2 = { + apiSecret: 'test-api-secret', + publicName: 'test tenant2', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret2', + walletAddressPrefix + } + + const tenant1Error = await tenantService.create(createOptions1) + assert(!isTenantError(tenant1Error)) + + const tenant2Error = await tenantService.create(createOptions2) + assert(isTenantError(tenant2Error)) + expect(tenant2Error).toEqual(TenantError.DuplicateWalletAddressPrefix) + }) }) describe('update', (): void => { @@ -241,7 +317,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest @@ -257,7 +334,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'second test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret-two' + idpSecret: 'test-idp-secret-two', + walletAddressPrefix: faker.internet.url() } const spy = jest @@ -279,7 +357,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest @@ -294,7 +373,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'second test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret-two' + idpSecret: 'test-idp-secret-two', + walletAddressPrefix: faker.internet.url() } const spy = jest @@ -329,7 +409,8 @@ describe('Tenant Service', (): void => { apiSecret: originalSecret, idpSecret: 'test-idp-secret', idpConsentUrl: faker.internet.url(), - deletedAt: new Date() + deletedAt: new Date(), + walletAddressPrefix: faker.internet.url() }) const spy = jest.spyOn(authServiceClient.tenant, 'update') @@ -348,6 +429,26 @@ describe('Tenant Service', (): void => { expect(spy).toHaveBeenCalledTimes(0) } }) + + test('Cannot update tenant prefix with invalid url', async (): Promise => { + const tenant = await createTenant(deps) + + const updatedTenantInfo = { + id: tenant.id, + walletAddressPrefix: 'invalid-url-prefix' + } + + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => undefined) + const tenantError = await tenantService.update(updatedTenantInfo) + assert(isTenantError(tenantError)) + expect(tenantError).toEqual(TenantError.InvalidTenantInput) + + const dbTenant = await tenantService.get(tenant.id) + assert(!isTenantError(dbTenant)) + expect(dbTenant?.walletAddressPrefix).toEqual(tenant.walletAddressPrefix) + }) }) describe('Delete Tenant', (): void => { @@ -357,7 +458,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest @@ -390,7 +492,8 @@ describe('Tenant Service', (): void => { email: faker.internet.url(), publicName: 'test name', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest @@ -439,7 +542,8 @@ describe('Tenant Service', (): void => { publicName: faker.company.name(), apiSecret: 'test-api-secret', idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url() } jest diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 05e88bd1ef..643bc2d0cf 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -1,7 +1,7 @@ import { validate as validateUuid } from 'uuid' import { Tenant } from './model' import { BaseService } from '../shared/baseService' -import { TransactionOrKnex } from 'objection' +import { TransactionOrKnex, UniqueViolationError } from 'objection' import { Pagination, SortOrder } from '../shared/baseModel' import { CacheDataStore } from '../middleware/cache/data-stores' import type { AuthServiceClient } from '../auth-service-client/client' @@ -14,10 +14,11 @@ import { TenantSettingInput } from '../graphql/generated/graphql' export interface TenantService { get: (id: string, includeDeleted?: boolean) => Promise create: (options: CreateTenantOptions) => Promise - update: (options: UpdateTenantOptions) => Promise + update: (options: UpdateTenantOptions) => Promise delete: (id: string) => Promise getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise updateOperatorApiSecretFromConfig: () => Promise + getTenantsByPrefix: (prefix: string) => Promise } export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex @@ -44,7 +45,8 @@ export async function createTenantService( getPage: (pagination, sortOrder) => getTenantPage(deps, pagination, sortOrder), updateOperatorApiSecretFromConfig: () => - updateOperatorApiSecretFromConfig(deps) + updateOperatorApiSecretFromConfig(deps), + getTenantsByPrefix: (prefix) => getTenantsByPrefix(deps, prefix) } } @@ -81,6 +83,7 @@ interface CreateTenantOptions { apiSecret: string idpSecret?: string idpConsentUrl?: string + walletAddressPrefix?: string publicName?: string settings?: TenantSettingInput[] } @@ -98,18 +101,28 @@ async function createTenant( publicName, idpSecret, idpConsentUrl, + walletAddressPrefix, settings } = options if (id && !validateUuid(id)) { throw TenantError.InvalidTenantId } + + if ( + walletAddressPrefix && + !validateWalletAddressPrefix(walletAddressPrefix) + ) { + throw TenantError.InvalidTenantInput + } + const tenant = await Tenant.query(trx).insertAndFetch({ id, email, publicName, apiSecret, idpSecret, - idpConsentUrl + idpConsentUrl, + walletAddressPrefix }) await deps.authServiceClient.tenant.create({ @@ -163,6 +176,8 @@ async function createTenant( return tenant } catch (err) { await trx.rollback() + if (err instanceof UniqueViolationError) + return TenantError.DuplicateWalletAddressPrefix if (isTenantError(err)) return err throw err } @@ -175,18 +190,33 @@ interface UpdateTenantOptions { apiSecret?: string idpConsentUrl?: string idpSecret?: string + walletAddressPrefix?: string } async function updateTenant( deps: ServiceDependencies, options: UpdateTenantOptions -): Promise { +): Promise { const trx = await deps.knex.transaction() try { - const { id, apiSecret, email, publicName, idpConsentUrl, idpSecret } = - options - const tenant = await Tenant.query(trx) + const { + id, + apiSecret, + email, + publicName, + idpConsentUrl, + idpSecret, + walletAddressPrefix + } = options + + if ( + walletAddressPrefix && + !validateWalletAddressPrefix(walletAddressPrefix) + ) + throw TenantError.InvalidTenantInput + + let tenant = await Tenant.query(trx) .patchAndFetchById(options.id, { email, publicName, @@ -205,11 +235,18 @@ async function updateTenant( }) } + if (walletAddressPrefix) { + tenant = await tenant.$query(trx).patchAndFetch({ + walletAddressPrefix + }) + } + await trx.commit() await deps.tenantCache.set(tenant.id, tenant) return tenant } catch (err) { await trx.rollback() + if (isTenantError(err)) return err throw err } } @@ -258,3 +295,22 @@ async function updateOperatorApiSecretFromConfig( await deps.tenantCache.set(operatorTenantId, tenant) } } + +async function getTenantsByPrefix( + deps: ServiceDependencies, + prefix: string +): Promise { + return await Tenant.query(deps.knex).whereILike( + 'walletAddressPrefix', + `${prefix}%` + ) +} + +function validateWalletAddressPrefix(prefix: string): boolean { + try { + new URL(prefix) + return true + } catch (err) { + return false + } +} diff --git a/packages/backend/src/tenants/settings/model.ts b/packages/backend/src/tenants/settings/model.ts index b64cd5b6dc..a2f9aa0be9 100644 --- a/packages/backend/src/tenants/settings/model.ts +++ b/packages/backend/src/tenants/settings/model.ts @@ -13,7 +13,6 @@ export const TenantSettingKeys: { [key: string]: TenantSettingKeyType } = { 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' } } @@ -59,7 +58,6 @@ const TENANT_KEY_MAPPING = { [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 @@ -104,6 +102,5 @@ export const TENANT_SETTING_VALIDATORS = { [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 index aaff3da822..e341bc4758 100644 --- a/packages/backend/src/tenants/settings/service.test.ts +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -22,8 +22,6 @@ import { 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' @@ -59,7 +57,8 @@ describe('TenantSetting Service', (): void => { apiSecret: faker.string.uuid(), email: faker.internet.email(), idpConsentUrl: faker.internet.url(), - idpSecret: faker.string.uuid() + idpSecret: faker.string.uuid(), + walletAddressPrefix: faker.internet.url() }) assert(!isTenantError(tenantOrError)) tenant = tenantOrError @@ -139,7 +138,6 @@ describe('TenantSetting Service', (): void => { ${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 => { @@ -163,7 +161,6 @@ describe('TenantSetting Service', (): void => { 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 => { @@ -286,28 +283,6 @@ describe('TenantSetting Service', (): void => { tenantSettingService.create(invalidIlpAddressSetting) ).resolves.toEqual(TenantSettingError.InvalidSetting) }) - - test('cannot create setting with a non-unique wallet address url', async (): Promise => { - const existingTenant = await createTenant(deps) - const existingSetting = [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: faker.internet.url() - } - ] - await createTenantSettings(deps, { - tenantId: existingTenant.id, - setting: existingSetting - }) - - const newTenant = await createTenant(deps) - await expect( - tenantSettingService.create({ - tenantId: newTenant.id, - setting: existingSetting - }) - ).resolves.toEqual(TenantSettingError.DuplicateWalletAddressUrl) - }) }) describe('get', () => { @@ -416,7 +391,6 @@ describe('TenantSetting Service', (): void => { ${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 => { @@ -436,7 +410,6 @@ describe('TenantSetting Service', (): void => { 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 => { @@ -535,29 +508,6 @@ describe('TenantSetting Service', (): void => { tenantSettingService.update(negativeOption) ).resolves.toEqual(TenantSettingError.InvalidSetting) }) - - test('cannot update wallet address url to already existing value', async (): Promise => { - const walletAddressUrl = faker.internet.url() - const existingTenant = await createTenant(deps) - await createTenantSettings(deps, { - tenantId: existingTenant.id, - setting: [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: walletAddressUrl - } - ] - }) - const newTenant = await createTenant(deps) - - await expect( - tenantSettingService.update({ - tenantId: newTenant.id, - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: walletAddressUrl - }) - ).resolves.toEqual(TenantSettingError.DuplicateWalletAddressUrl) - }) }) describe('delete', (): void => { @@ -723,67 +673,4 @@ describe('TenantSetting Service', (): void => { 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 index 0a7a7e4754..c7b3489507 100644 --- a/packages/backend/src/tenants/settings/service.ts +++ b/packages/backend/src/tenants/settings/service.ts @@ -50,7 +50,6 @@ export interface TenantSettingService { pagination?: Pagination, sortOrder?: SortOrder ) => Promise - getSettingsByPrefix: (prefix: string) => Promise } export interface ServiceDependencies extends BaseService { @@ -76,9 +75,7 @@ export async function createTenantSettingService( tenantId: string, pagination?: Pagination, sortOrder?: SortOrder - ) => getTenantSettingPageForTenant(deps, tenantId, pagination, sortOrder), - getSettingsByPrefix: (prefix: string) => - getWalletAddressSettingsByPrefix(deps, prefix) + ) => getTenantSettingPageForTenant(deps, tenantId, pagination, sortOrder) } } @@ -121,17 +118,6 @@ async function updateTenantSetting( return TenantSettingError.InvalidSetting } - if (options.key === TenantSettingKeys.WALLET_ADDRESS_URL.name) { - const existingSetting = await TenantSetting.query(deps.knex).findOne({ - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: options.value - }) - - if (existingSetting) { - return TenantSettingError.DuplicateWalletAddressUrl - } - } - return TenantSetting.query(deps.knex) .patch({ value: options.value }) .whereNull('deletedAt') @@ -153,19 +139,6 @@ async function createTenantSetting( ) { return TenantSettingError.InvalidSetting } - - if (setting.key === TenantSettingKeys.WALLET_ADDRESS_URL.name) { - const existingSetting = await TenantSetting.query( - extra?.trx ?? deps.knex - ).findOne({ - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: setting.value - }) - - if (existingSetting) { - return TenantSettingError.DuplicateWalletAddressUrl - } - } } const dataToUpsert = options.setting @@ -197,14 +170,3 @@ async function getTenantSettingPageForTenant( .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/tenant.ts b/packages/backend/src/tests/tenant.ts index 94d907f6b4..0c95abf06e 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -14,11 +14,12 @@ import { TestContainer } from './app' import { isTenantError } from '../tenants/errors' interface CreateOptions { - email: string + email?: string publicName?: string - apiSecret: string - idpConsentUrl: string - idpSecret: string + apiSecret?: string + idpConsentUrl?: string + idpSecret?: string + walletAddressPrefix?: string } export function createTenantedApolloClient( @@ -63,7 +64,8 @@ export function generateTenantInput() { apiSecret: faker.string.alphanumeric(8), idpConsentUrl: faker.internet.url(), idpSecret: faker.string.alphanumeric(8), - publicName: faker.company.name() + publicName: faker.company.name(), + walletAddressPrefix: faker.internet.url() } } @@ -76,15 +78,15 @@ export async function createTenant( 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' - } - ) + const tenantOrError = await tenantService.create({ + email: faker.internet.email(), + apiSecret: 'test-api-secret', + publicName: faker.company.name(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url(), + ...options + }) if (!tenantOrError || isTenantError(tenantOrError)) { throw Error('Failed to create test tenant') diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index 65b176705a..4602461f1e 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -1,8 +1,8 @@ import axios from 'axios' import { IocContract } from '@adonisjs/fold' -import { faker } from '@faker-js/faker' import { Scope } from 'nock' import { URL } from 'url' +import assert from 'assert' import { testAccessToken } from './app' import { createAsset } from './asset' @@ -12,8 +12,7 @@ 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' +import { v4 } from 'uuid' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -31,28 +30,25 @@ export async function createWalletAddress( deps: IocContract, options: Partial = {} ): Promise { + const tenantService = await deps.use('tenantService') const walletAddressService = await deps.use('walletAddressService') - const tenantIdToUse = options.tenantId || (await createTenant(deps)).id + const tenantToUse = options.tenantId + ? await tenantService.get(options.tenantId) + : await createTenant(deps) + assert.ok(tenantToUse) const baseWalletAddressUrl = new URL( - options.address || `https://${faker.internet.domainName()}` + options.address || tenantToUse.walletAddressPrefix ) - 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, { tenantId: tenantIdToUse })).id, - tenantId: tenantIdToUse, - address: options.address || `${baseWalletAddressUrl.origin}/.well-known/pay` + (await createAsset(deps, { tenantId: tenantToUse.id })).id, + tenantId: tenantToUse.id, + address: + options.address || `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) diff --git a/packages/card-service/src/graphql/generated/graphql.ts b/packages/card-service/src/graphql/generated/graphql.ts index d46983b238..fdc257f3c9 100644 --- a/packages/card-service/src/graphql/generated/graphql.ts +++ b/packages/card-service/src/graphql/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3094,7 +3100,7 @@ export type ListTenantsQueryVariables = Exact<{ }>; -export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, walletAddressPrefix: string, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateTenantMutationVariables = Exact<{ input: CreateTenantInput; @@ -3122,7 +3128,7 @@ export type GetTenantQueryVariables = Exact<{ }>; -export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, walletAddressPrefix: string, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/packages/frontend/app/components/ui/Select.tsx b/packages/frontend/app/components/ui/Select.tsx index 2d604ae374..cefa1858b7 100644 --- a/packages/frontend/app/components/ui/Select.tsx +++ b/packages/frontend/app/components/ui/Select.tsx @@ -22,7 +22,7 @@ type SelectProps = { defaultValue?: SelectOption description?: ReactNode onChange?: (value: React.SetStateAction) => void - bringForward?: boolean + bringForward?: 1 | 2 } export const Select = ({ @@ -69,7 +69,9 @@ export const Select = ({ {name ? ( ) : null} -
+
{label ? ( {label} diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index c4cdb4a4c4..15af70211d 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3080,7 +3086,7 @@ export type ListTenantsQueryVariables = Exact<{ }>; -export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, walletAddressPrefix: string, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateTenantMutationVariables = Exact<{ input: CreateTenantInput; @@ -3108,7 +3114,7 @@ export type GetTenantQueryVariables = Exact<{ }>; -export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, walletAddressPrefix: string, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/packages/frontend/app/lib/api/tenant.server.ts b/packages/frontend/app/lib/api/tenant.server.ts index 0d7d1f2762..729424c2fc 100644 --- a/packages/frontend/app/lib/api/tenant.server.ts +++ b/packages/frontend/app/lib/api/tenant.server.ts @@ -56,6 +56,7 @@ export const listTenants = async (request: Request, args: QueryTenantsArgs) => { apiSecret idpConsentUrl idpSecret + walletAddressPrefix publicName createdAt deletedAt @@ -180,6 +181,7 @@ export const getTenantInfo = async ( idpConsentUrl idpSecret publicName + walletAddressPrefix createdAt deletedAt settings { diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index 63e6415bb9..8a288713ed 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -134,7 +134,8 @@ export const updateWalletAddressSchema = z export const updateTenantGeneralSchema = z .object({ publicName: z.string().optional(), - email: z.string().email().or(z.literal('')) + email: z.string().email().or(z.literal('')), + walletAddressPrefix: z.string().url().or(z.literal('')) }) .merge(uuidSchema) @@ -159,7 +160,7 @@ export const tenantSettingsSchema = z.object({ webhookUrl: z.string().url().or(z.literal('')).optional(), webhookTimeout: z.coerce.number().or(z.literal('')).optional(), webhookMaxRetry: z.coerce.number().or(z.literal('')).optional(), - walletAddressUrl: z.string().or(z.literal('')).optional(), + walletAddressPrefix: z.string().url().or(z.literal('')), ilpAddress: z.string().optional() }) diff --git a/packages/frontend/app/routes/peers.create.tsx b/packages/frontend/app/routes/peers.create.tsx index eaa520e2d4..a31cae3d98 100644 --- a/packages/frontend/app/routes/peers.create.tsx +++ b/packages/frontend/app/routes/peers.create.tsx @@ -242,7 +242,7 @@ export default function CreatePeerPage() { will sent to & received from the peer. } - bringForward + bringForward={1} /> ) : (
{!tenantDeleted && ( diff --git a/packages/frontend/app/routes/tenants.create.tsx b/packages/frontend/app/routes/tenants.create.tsx index 9785e1c3b8..bd1d21d305 100644 --- a/packages/frontend/app/routes/tenants.create.tsx +++ b/packages/frontend/app/routes/tenants.create.tsx @@ -34,7 +34,7 @@ export default function CreateTenantPage() { const [webhookUrl, setWebhookUrl] = useState() const [webhookTimeout, setWebhookTimeout] = useState() const [webhookMaxRetry, setWebhookMaxRetry] = useState() - const [walletAddressUrl, setWalletAddressUrl] = useState() + const [walletAddressPrefix, setWalletAddressPrefix] = useState() const [ilpAddress, setIlpAddress] = useState() const tenantSettings: { @@ -48,6 +48,13 @@ export default function CreateTenantPage() { placeholder: string label: string }[] = [ + { + name: 'walletAddressPrefix', + placeholder: 'Wallet Address Url', + label: 'Wallet Address Url', + value: walletAddressPrefix, + setValue: setWalletAddressPrefix + }, { name: 'exchangeRatesUrl', placeholder: 'Exhange Rates Url', @@ -76,13 +83,6 @@ export default function CreateTenantPage() { value: webhookMaxRetry, setValue: setWebhookMaxRetry }, - { - name: 'walletAddressUrl', - placeholder: 'Wallet Address Url', - label: 'Wallet Address Url', - value: walletAddressUrl, - setValue: setWalletAddressUrl - }, { name: 'ilpAddress', placeholder: 'ILP Address', @@ -256,7 +256,6 @@ export async function action({ request }: ActionFunctionArgs) { webhookUrl, webhookTimeout, webhookMaxRetry, - walletAddressUrl, ilpAddress, ...restOfData } = result.data @@ -266,7 +265,6 @@ export async function action({ request }: ActionFunctionArgs) { webhookUrl, webhookTimeout, webhookMaxRetry, - walletAddressUrl, ilpAddress } const settingNameToKey = { @@ -274,7 +272,6 @@ export async function action({ request }: ActionFunctionArgs) { webhookUrl: TenantSettingKey.WebhookUrl, webhookTimeout: TenantSettingKey.WebhookTimeout, webhookMaxRetry: TenantSettingKey.WebhookMaxRetry, - walletAddressUrl: TenantSettingKey.WalletAddressUrl, ilpAddress: TenantSettingKey.IlpAddress } const tenantSettings = [] diff --git a/packages/frontend/app/routes/wallet-addresses.create.tsx b/packages/frontend/app/routes/wallet-addresses.create.tsx index 10753d723c..0d58fb3361 100644 --- a/packages/frontend/app/routes/wallet-addresses.create.tsx +++ b/packages/frontend/app/routes/wallet-addresses.create.tsx @@ -14,25 +14,12 @@ import { createWalletAddress } from '~/lib/api/wallet-address.server' import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { createWalletAddressSchema } from '~/lib/validate.server' import type { ZodFieldErrors } from '~/shared/types' -import { - getOpenPaymentsUrl, - removeTrailingAndLeadingSlash -} from '~/shared/utils' +import { removeTrailingAndLeadingSlash } from '~/shared/utils' import { checkAuthAndRedirect } from '../lib/kratos_checks.server' import { type LoaderFunctionArgs } from '@remix-run/node' import type { listTenants } from '~/lib/api/tenant.server' import { whoAmI, loadTenants, getTenantInfo } from '~/lib/api/tenant.server' -const WALLET_ADDRESS_URL_KEY = 'WALLET_ADDRESS_URL' - -const findWASetting = ( - tenantSettings: Awaited>['settings'] -) => { - return tenantSettings.find( - (setting) => setting.key === WALLET_ADDRESS_URL_KEY - )?.value -} - const findTenant = ( tenants: Awaited>['edges'], tenantId: string @@ -49,14 +36,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { let tenants let tenantWAPrefix if (isOperator) { - const loadedTenants = await loadTenants(request) - tenants = loadedTenants.filter( - (tenant) => findWASetting(tenant.node.settings) || tenant.node.id === id - ) + tenants = await loadTenants(request) } else { const tenant = await getTenantInfo(request, { id }) - const waPrefixSetting = findWASetting(tenant.settings) - tenantWAPrefix = waPrefixSetting ?? getOpenPaymentsUrl() + tenantWAPrefix = tenant.walletAddressPrefix } return json({ assets, tenants, tenantWAPrefix }) } @@ -80,9 +63,6 @@ export default function CreateWalletAddressPage() { const currentTenant = tenants && tenantId ? findTenant(tenants, tenantId.value) : null - const waPrefix = currentTenant - ? findWASetting(currentTenant.node.settings) - : tenantWAPrefix return (
@@ -107,25 +87,6 @@ export default function CreateWalletAddressPage() {
- - - {tenants ? ( )} {tenants && tenantId && ( @@ -160,8 +122,32 @@ export default function CreateWalletAddressPage() { placeholder='Select asset...' label='Asset' required + bringForward={1} /> )} + + +
diff --git a/packages/frontend/app/styles/tailwind.css b/packages/frontend/app/styles/tailwind.css index ba6adb470b..250c36d6ba 100644 --- a/packages/frontend/app/styles/tailwind.css +++ b/packages/frontend/app/styles/tailwind.css @@ -7,6 +7,10 @@ a.default-link { text-decoration: revert; } -div.forward { +div.forward-1 { z-index: 1; } + +div.forward-2 { + z-index: 2; +} diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 7d20ea747b..416d0b0cf6 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/mock-account-service-lib/src/requesters.ts b/packages/mock-account-service-lib/src/requesters.ts index 101387418c..d3a25a6229 100644 --- a/packages/mock-account-service-lib/src/requesters.ts +++ b/packages/mock-account-service-lib/src/requesters.ts @@ -143,11 +143,8 @@ export async function createTenant( publicName, idpConsentUrl, idpSecret, + walletAddressPrefix: walletAddressUrl, settings: [ - { - key: TenantSettingKey.WalletAddressUrl, - value: walletAddressUrl - }, { key: TenantSettingKey.WebhookUrl, value: webhookUrl diff --git a/packages/point-of-sale/src/graphql/generated/graphql.ts b/packages/point-of-sale/src/graphql/generated/graphql.ts index 06ea5cddac..9ffda54321 100644 --- a/packages/point-of-sale/src/graphql/generated/graphql.ts +++ b/packages/point-of-sale/src/graphql/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3080,7 +3086,7 @@ export type ListTenantsQueryVariables = Exact<{ }>; -export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, walletAddressPrefix: string, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateTenantMutationVariables = Exact<{ input: CreateTenantInput; @@ -3108,7 +3114,7 @@ export type GetTenantQueryVariables = Exact<{ }>; -export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, walletAddressPrefix: string, createdAt: string, deletedAt?: string | null, settings: Array<{ __typename?: 'TenantSetting', key: TenantSettingKey, value: string }> } }; export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index 7d20ea747b..416d0b0cf6 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -401,6 +401,8 @@ export type CreateTenantInput = { publicName?: InputMaybe; /** Initial settings for tenant. */ settings?: InputMaybe>; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { @@ -1538,6 +1540,8 @@ export type Tenant = Model & { publicName?: Maybe; /** List of settings for the tenant. */ settings: Array; + /** Prefix for wallet addresses belonging to this tenant. */ + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -1571,7 +1575,6 @@ export type TenantSettingInput = { export enum TenantSettingKey { ExchangeRatesUrl = 'EXCHANGE_RATES_URL', IlpAddress = 'ILP_ADDRESS', - WalletAddressUrl = 'WALLET_ADDRESS_URL', WebhookMaxRetry = 'WEBHOOK_MAX_RETRY', WebhookTimeout = 'WEBHOOK_TIMEOUT', WebhookUrl = 'WEBHOOK_URL' @@ -1670,6 +1673,8 @@ export type UpdateTenantInput = { idpSecret?: InputMaybe; /** Public name for the tenant. */ publicName?: InputMaybe; + /** Prefix for all wallet addresses belonging to this tenant. */ + walletAddressPrefix?: InputMaybe; }; export type UpdateWalletAddressInput = { @@ -2682,6 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; };