From 2451cd0b6e860dbaf5b58c5b4ef6ff7779b251fe Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Thu, 9 Oct 2025 09:01:57 -0700 Subject: [PATCH 1/9] feat(backend): enforce uniqueness on tenant wallet address prefixes --- .../generated/graphql.ts | 8 +- .../20250930210751_add_prefix_to_tenant.js | 34 ++++++ packages/backend/src/asset/service.test.ts | 16 +-- .../src/graphql/generated/graphql.schema.json | 42 ++++++- .../backend/src/graphql/generated/graphql.ts | 8 +- .../backend/src/graphql/resolvers/tenant.ts | 9 +- .../src/graphql/resolvers/tenant_settings.ts | 2 - .../graphql/resolvers/wallet_address.test.ts | 44 ++----- packages/backend/src/graphql/schema.graphql | 7 +- packages/backend/src/index.ts | 1 + .../open_payments/wallet_address/errors.ts | 8 +- .../wallet_address/service.test.ts | 82 ++++++------- .../open_payments/wallet_address/service.ts | 22 ++-- packages/backend/src/tenants/errors.ts | 6 +- packages/backend/src/tenants/model.ts | 1 + packages/backend/src/tenants/service.test.ts | 108 +++++++++++++++++- packages/backend/src/tenants/service.ts | 70 ++++++++++-- .../backend/src/tenants/settings/model.ts | 3 - .../src/tenants/settings/service.test.ts | 69 ----------- .../backend/src/tenants/settings/service.ts | 16 +-- packages/backend/src/tests/tenant.ts | 26 ++--- packages/backend/src/tests/walletAddress.ts | 41 ++++--- packages/frontend/app/generated/graphql.ts | 10 +- .../frontend/app/lib/api/tenant.server.ts | 1 + packages/frontend/app/lib/validate.server.ts | 5 +- .../frontend/app/routes/tenants.$tenantId.tsx | 9 ++ .../frontend/app/routes/tenants.create.tsx | 19 ++- .../src/generated/graphql.ts | 8 +- .../src/requesters.ts | 5 +- test/test-lib/src/generated/graphql.ts | 8 +- 30 files changed, 416 insertions(+), 272 deletions(-) create mode 100644 packages/backend/migrations/20250930210751_add_prefix_to_tenant.js diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 7d20ea747b..e51068c733 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?: InputMaybe; }; 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?: Maybe; }; 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, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; 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..da6607ccd9 --- /dev/null +++ b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js @@ -0,0 +1,34 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('tenants', (table) => { + table.string('walletAddressPrefix').unique() + }) + .then(() => { + knex.raw( + `UPDATE "tenants" SET "walletAddressPrefix" = (SELECT "value" from "tenantSettings" WHERE "tenantId" = "tenants"."id" AND "key" = 'WALLET_ADDRESS_URL')` + ) + }) + .then(() => { + knex.raw(`DELETE "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'`) + }) +} + +/** + * @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..cfe1b1dcc9 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -2398,6 +2398,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, @@ -8787,6 +8799,18 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "walletAddressPrefix", + "description": "Prefix for wallet addresses belonging to this tenant.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -8981,12 +9005,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "WALLET_ADDRESS_URL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "WEBHOOK_MAX_RETRY", "description": null, @@ -9520,6 +9538,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..e51068c733 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?: InputMaybe; }; 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?: Maybe; }; 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, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; 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.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..b3e0a23b63 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -47,9 +47,8 @@ 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' describe('Wallet Address Resolvers', (): void => { let deps: IocContract @@ -57,6 +56,7 @@ describe('Wallet Address Resolvers', (): void => { let knex: Knex let walletAddressService: WalletAddressService let assetService: AssetService + let tenantService: TenantService beforeAll(async (): Promise => { deps = initIocContainer({ @@ -67,6 +67,7 @@ describe('Wallet Address Resolvers', (): void => { knex = appContainer.knex walletAddressService = await deps.use('walletAddressService') assetService = await deps.use('assetService') + tenantService = await deps.use('tenantService') }) afterEach(async (): Promise => { @@ -79,14 +80,9 @@ describe('Wallet Address Resolvers', (): 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' }) }) @@ -396,7 +392,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 +402,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 +795,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 +805,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..4ad021265d 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..343b7011a9 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' + WalletAddressPrefixNotFound = 'WalletAddressPrefixNotFound' } // 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.WalletAddressPrefixNotFound]: GraphQLErrorCode.NotFound } export const errorToMessage: { @@ -30,6 +30,6 @@ 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.WalletAddressPrefixNotFound]: + 'Prefix configuration for wallet address has not been found.' } 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..8d385a09ed 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 } @@ -113,22 +103,15 @@ describe('Open Payments Wallet Address Service', (): void => { 'operator - $isOperator with tenantSettingUrl - $tenantSettingUrl', async ({ isOperator, tenantSettingUrl }): Promise => { const address = 'test' - const tempTenant = await createTenant(deps) + const tempTenant = await createTenant(deps, { + walletAddressPrefix: tenantSettingUrl + }) const { id: tempAssetId } = await createAsset(deps, { tenantId: tempTenant.id }) - let expected: string = WalletAddressError.WalletAddressSettingNotFound + let expected: string = WalletAddressError.WalletAddressPrefixNotFound if (tenantSettingUrl) { - await createTenantSettings(deps, { - tenantId: tempTenant.id, - setting: [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: tenantSettingUrl - } - ] - }) expected = `${tenantSettingUrl}/${address}` } else { if (isOperator) { @@ -161,7 +144,7 @@ describe('Open Payments Wallet Address Service', (): void => { ...options, tenantId: tempTenant.id }) - ).toEqual(WalletAddressError.WalletAddressSettingNotFound) + ).toEqual(WalletAddressError.WalletAddressPrefixNotFound) }) test('should return InvalidUrl error if wallet address URL does not start with tenant wallet address URL', async (): Promise => { @@ -173,21 +156,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'} + setting | 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 } - ] + const tenant = await createTenant(deps, { + walletAddressPrefix: setting }) + const asset = await createAsset(deps, { tenantId: tenant.id }) + const options = { + tenantId: tenant.id, + assetId: asset.id + } const walletAddress = await walletAddressService.create({ ...options, @@ -236,7 +221,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 +231,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 +601,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 +639,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 +651,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..ffe9e32af9 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 { @@ -176,17 +179,14 @@ async function createWalletAddressUrl( ): Promise { let tenantWalletAddressUrl = new URL(deps.config.openPaymentsUrl) - const found = await deps.tenantSettingService.get({ - tenantId: options.tenantId, - key: TenantSettingKeys.WALLET_ADDRESS_URL.name - }) + const tenant = await deps.tenantService.get(options.tenantId) - if (!found || found.length === 0) { + if (!tenant?.walletAddressPrefix) { if (!options.isOperator) { - return WalletAddressError.WalletAddressSettingNotFound + return WalletAddressError.WalletAddressPrefixNotFound } } else { - tenantWalletAddressUrl = new URL(found[0].value) + tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) } let tenantBaseUrl = tenantWalletAddressUrl.toString() @@ -207,7 +207,7 @@ async function createWalletAddressUrl( 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) + const walletAddressUrl = new URL(options.address.toLowerCase()) if (!walletAddressUrl.href.startsWith(tenantWalletAddressUrl.href)) { return WalletAddressError.InvalidUrl } @@ -383,8 +383,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/tenants/errors.ts b/packages/backend/src/tenants/errors.ts index 5bfba012df..7a19a3a2fd 100644 --- a/packages/backend/src/tenants/errors.ts +++ b/packages/backend/src/tenants/errors.ts @@ -1,6 +1,7 @@ export enum TenantError { TenantNotFound = 'TenantNotFound', - InvalidTenantId = 'InvalidTenantId' + InvalidTenantId = 'InvalidTenantId', + InvalidTenantInput = 'InvalidTenantInput' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -11,5 +12,6 @@ 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' } 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..fc7072b56e 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -113,6 +113,30 @@ 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()}` + 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 +146,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,7 +174,7 @@ 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', @@ -158,8 +183,8 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret', settings: [ { - key: SchemaTenantSettingKey.WalletAddressUrl, - value: walletAddressUrl + key: SchemaTenantSettingKey.WebhookUrl, + value: webhookUrl } ] } @@ -172,10 +197,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 => { @@ -232,6 +257,21 @@ 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) + }) }) describe('update', (): void => { @@ -322,6 +362,42 @@ describe('Tenant Service', (): void => { } }) + test('rolls back tenant if wallet address prefix is already set', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + walletAddressPrefix: `${config.openPaymentsUrl}/${v4()}` + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(originalTenantInfo) + assert(!isTenantError(tenant)) + + const updatedTenantInfo = { + id: tenant.id, + walletAddressPrefix: `${config.openPaymentsUrl}/${v4()}` + } + + 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( + originalTenantInfo.walletAddressPrefix + ) + }) + test('Cannot update deleted tenant', async (): Promise => { const originalSecret = 'test-secret' const dbTenant = await Tenant.query(knex).insertAndFetch({ @@ -348,6 +424,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).toBeNull() + }) }) describe('Delete Tenant', (): void => { diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 05e88bd1ef..f3cd34297e 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -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({ @@ -175,18 +188,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 +233,20 @@ async function updateTenant( }) } + if (!tenant.walletAddressPrefix && walletAddressPrefix) { + tenant = await tenant.$query(trx).patchAndFetch({ + walletAddressPrefix: walletAddressPrefix.toLowerCase() + }) + } else if (tenant.walletAddressPrefix && walletAddressPrefix) { + throw TenantError.InvalidTenantInput + } + 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..ed8ace484c 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' @@ -139,7 +137,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 +160,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 => { @@ -416,7 +412,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 +431,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 => { @@ -723,67 +717,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..b65fc9b644 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) } } @@ -197,14 +194,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..9dc7ae8b18 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( @@ -76,15 +77,14 @@ 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', + ...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..f4fa1c3de8 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -3,6 +3,7 @@ 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 +13,8 @@ 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 { isTenantError } from '../tenants/errors' +import { v4 } from 'uuid' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -31,28 +32,36 @@ 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( + let baseWalletAddressUrl = new URL( options.address || `https://${faker.internet.domainName()}` ) - await createTenantSettings(deps, { - tenantId: tenantIdToUse, - setting: [ - { - key: TenantSettingKeys.WALLET_ADDRESS_URL.name, - value: baseWalletAddressUrl.origin - } - ] - }) + + if (!tenantToUse.walletAddressPrefix && tenantToUse.id) { + const updatedTenant = await tenantService.update({ + id: tenantToUse.id, + walletAddressPrefix: baseWalletAddressUrl.origin + }) + assert(!isTenantError(updatedTenant)) + } else { + baseWalletAddressUrl = new URL(tenantToUse.walletAddressPrefix) + } + 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.origin}/${v4()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index c4cdb4a4c4..fd9f605eff 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?: InputMaybe; }; 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?: Maybe; }; 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, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -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 | null, 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..004fb6c119 100644 --- a/packages/frontend/app/lib/api/tenant.server.ts +++ b/packages/frontend/app/lib/api/tenant.server.ts @@ -180,6 +180,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..7339f4199b 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('')).optional(), ilpAddress: z.string().optional() }) diff --git a/packages/frontend/app/routes/tenants.$tenantId.tsx b/packages/frontend/app/routes/tenants.$tenantId.tsx index 846de98532..6c6347e7b1 100644 --- a/packages/frontend/app/routes/tenants.$tenantId.tsx +++ b/packages/frontend/app/routes/tenants.$tenantId.tsx @@ -120,6 +120,15 @@ export default function ViewTenantPage() { defaultValue={tenant.email ?? undefined} error={response?.errors?.general.fieldErrors.email} /> +
{!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/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 7d20ea747b..e51068c733 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?: InputMaybe; }; 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?: Maybe; }; 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, ParentType, ContextType>; __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/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index 7d20ea747b..e51068c733 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?: InputMaybe; }; 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?: Maybe; }; 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, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; From 5a5b45b890660c7465922e718b9d43de30c74991 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 10 Oct 2025 12:44:57 -0700 Subject: [PATCH 2/9] feat(wip): schema enforced wallet address prefixes --- .../Rafiki Admin APIs/Create Tenant.bru | 4 +- .../generated/graphql.ts | 2 +- packages/backend/jest.env.js | 4 +- .../20250930210751_add_prefix_to_tenant.js | 12 +- .../src/graphql/generated/graphql.schema.json | 10 +- .../backend/src/graphql/generated/graphql.ts | 2 +- .../src/graphql/resolvers/tenant.test.ts | 4 + .../graphql/resolvers/wallet_address.test.ts | 18 ++- packages/backend/src/graphql/schema.graphql | 2 +- .../open_payments/wallet_address/errors.ts | 10 +- .../wallet_address/service.test.ts | 10 +- .../open_payments/wallet_address/service.ts | 13 +-- packages/backend/src/shared/utils.test.ts | 6 +- packages/backend/src/tenants/errors.ts | 6 +- packages/backend/src/tenants/service.test.ts | 105 +++++++++--------- packages/backend/src/tenants/service.ts | 13 +-- .../src/tenants/settings/service.test.ts | 3 +- packages/backend/src/tests/tenant.ts | 4 +- packages/backend/src/tests/walletAddress.ts | 15 +-- packages/frontend/app/generated/graphql.ts | 2 +- packages/frontend/app/lib/validate.server.ts | 2 +- .../src/generated/graphql.ts | 2 +- test/test-lib/src/generated/graphql.ts | 2 +- 23 files changed, 134 insertions(+), 117 deletions(-) diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru index c078d15371..f30c8f3727 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://google.com" } } } diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index e51068c733..fef9586b9b 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -402,7 +402,7 @@ export type CreateTenantInput = { /** Initial settings for tenant. */ settings?: InputMaybe>; /** Prefix for all wallet addresses belonging to this tenant. */ - walletAddressPrefix?: InputMaybe; + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 99b6ceccad..185094512f 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -1,7 +1,7 @@ -process.env.LOG_LEVEL = 'silent' +process.env.LOG_LEVEL = 'info' 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 index da6607ccd9..2d58748daf 100644 --- a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js +++ b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js @@ -8,12 +8,20 @@ exports.up = function (knex) { table.string('walletAddressPrefix').unique() }) .then(() => { - knex.raw( + return knex.raw( `UPDATE "tenants" SET "walletAddressPrefix" = (SELECT "value" from "tenantSettings" WHERE "tenantId" = "tenants"."id" AND "key" = 'WALLET_ADDRESS_URL')` ) }) .then(() => { - knex.raw(`DELETE "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'`) + return knex.raw(`DELETE FROM "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'`) + }) + .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') + }) }) } diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index cfe1b1dcc9..f6a1809330 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -2403,9 +2403,13 @@ "name": "walletAddressPrefix", "description": "Prefix for all wallet addresses belonging to this tenant.", "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "defaultValue": null, "isDeprecated": false, diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index e51068c733..fef9586b9b 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -402,7 +402,7 @@ export type CreateTenantInput = { /** Initial settings for tenant. */ settings?: InputMaybe>; /** Prefix for all wallet addresses belonging to this tenant. */ - walletAddressPrefix?: InputMaybe; + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { 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/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index b3e0a23b63..bc27e3435d 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -17,7 +17,7 @@ import { IocContract } from '@adonisjs/fold' import { AppServices } from '../../app' import { Asset } from '../../asset/model' import { initIocContainer } from '../..' -import { Config } from '../../config/app' +import { Config, IAppConfig } from '../../config/app' import { truncateTables } from '../../tests/tableManager' import { WalletAddressError, @@ -49,6 +49,7 @@ import { faker } from '@faker-js/faker' import { Tenant } from '../../tenants/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 +58,7 @@ describe('Wallet Address Resolvers', (): void => { let walletAddressService: WalletAddressService let assetService: AssetService let tenantService: TenantService + let prefix: string beforeAll(async (): Promise => { deps = initIocContainer({ @@ -68,6 +70,13 @@ describe('Wallet Address Resolvers', (): void => { 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 => { @@ -80,10 +89,7 @@ describe('Wallet Address Resolvers', (): void => { }) beforeEach(async () => { - await tenantService.update({ - id: Config.operatorTenantId, - walletAddressPrefix: 'https://alice.me' - }) + }) describe('Create Wallet Address', (): void => { @@ -95,7 +101,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` } }) diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 4ad021265d..2e5ebf79b2 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1694,7 +1694,7 @@ input CreateTenantInput { "Public name for the tenant." publicName: String "Prefix for all wallet addresses belonging to this tenant." - walletAddressPrefix: String + walletAddressPrefix: String! "Initial settings for tenant." settings: [TenantSettingInput!] } diff --git a/packages/backend/src/open_payments/wallet_address/errors.ts b/packages/backend/src/open_payments/wallet_address/errors.ts index 343b7011a9..03d672762c 100644 --- a/packages/backend/src/open_payments/wallet_address/errors.ts +++ b/packages/backend/src/open_payments/wallet_address/errors.ts @@ -4,8 +4,7 @@ export enum WalletAddressError { InvalidUrl = 'InvalidUrl', UnknownAsset = 'UnknownAsset', UnknownWalletAddress = 'UnknownWalletAddress', - DuplicateWalletAddress = 'DuplicateWalletAddress', - WalletAddressPrefixNotFound = 'WalletAddressPrefixNotFound' + DuplicateWalletAddress = 'DuplicateWalletAddress' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -18,8 +17,7 @@ export const errorToCode: { [WalletAddressError.InvalidUrl]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownAsset]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownWalletAddress]: GraphQLErrorCode.NotFound, - [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate, - [WalletAddressError.WalletAddressPrefixNotFound]: GraphQLErrorCode.NotFound + [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate } export const errorToMessage: { @@ -29,7 +27,5 @@ export const errorToMessage: { [WalletAddressError.UnknownAsset]: 'unknown asset', [WalletAddressError.UnknownWalletAddress]: 'unknown wallet address', [WalletAddressError.DuplicateWalletAddress]: - 'Duplicate wallet address found with the same url', - [WalletAddressError.WalletAddressPrefixNotFound]: - 'Prefix configuration for wallet address has not been found.' + 'Duplicate wallet address found with the same url' } 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 8d385a09ed..a12f481ff1 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -110,7 +110,7 @@ describe('Open Payments Wallet Address Service', (): void => { tenantId: tempTenant.id }) - let expected: string = WalletAddressError.WalletAddressPrefixNotFound + let expected: string if (tenantSettingUrl) { expected = `${tenantSettingUrl}/${address}` } else { @@ -156,17 +156,17 @@ describe('Open Payments Wallet Address Service', (): void => { }) test.each` - setting | address | generated + 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 => { + 'should create address $generated with address $address and prefix $prefix', + async ({ prefix, address, generated }): Promise => { const tenant = await createTenant(deps, { - walletAddressPrefix: setting + walletAddressPrefix: prefix }) const asset = await createAsset(deps, { tenantId: tenant.id }) const options = { diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index ffe9e32af9..7ff0ea94fa 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -177,17 +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) - if (!tenant?.walletAddressPrefix) { - if (!options.isOperator) { - return WalletAddressError.WalletAddressPrefixNotFound - } - } else { - tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) - } + if (!tenant) return WalletAddressError.UnknownTenant + let tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) let tenantBaseUrl = tenantWalletAddressUrl.toString() if (!tenantWalletAddressUrl.pathname.endsWith('/')) { @@ -207,7 +200,7 @@ async function createWalletAddressUrl( 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.toLowerCase()) + const walletAddressUrl = new URL(options.address) if (!walletAddressUrl.href.startsWith(tenantWalletAddressUrl.href)) { return WalletAddressError.InvalidUrl } 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 7a19a3a2fd..70a609d609 100644 --- a/packages/backend/src/tenants/errors.ts +++ b/packages/backend/src/tenants/errors.ts @@ -1,7 +1,8 @@ export enum TenantError { TenantNotFound = 'TenantNotFound', InvalidTenantId = 'InvalidTenantId', - InvalidTenantInput = 'InvalidTenantInput' + InvalidTenantInput = 'InvalidTenantInput', + DuplicateWalletAddressPrefix = 'DuplicateWalletAddressPrefix' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -13,5 +14,6 @@ export const errorToMessage: { } = { [TenantError.TenantNotFound]: 'Tenant not found', [TenantError.InvalidTenantId]: 'Invalid Tenant ID', - [TenantError.InvalidTenantInput]: 'Invalid Tenant input' + [TenantError.InvalidTenantInput]: 'Invalid Tenant input', + [TenantError.DuplicateWalletAddressPrefix]: 'Duplicate Wallet Address prefix' } diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index fc7072b56e..eba06c8837 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) @@ -181,6 +184,7 @@ describe('Tenant Service', (): void => { email: faker.internet.email(), idpConsentUrl: faker.internet.url(), idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url(), settings: [ { key: SchemaTenantSettingKey.WebhookUrl, @@ -211,7 +215,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 @@ -229,7 +234,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 @@ -272,6 +278,33 @@ describe('Tenant Service', (): void => { 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 => { @@ -281,7 +314,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 @@ -297,7 +331,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 @@ -319,7 +354,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 @@ -334,7 +370,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 @@ -362,42 +399,6 @@ describe('Tenant Service', (): void => { } }) - test('rolls back tenant if wallet address prefix is already set', async (): Promise => { - const originalTenantInfo = { - apiSecret: 'test-api-secret', - email: faker.internet.url(), - publicName: 'test name', - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret', - walletAddressPrefix: `${config.openPaymentsUrl}/${v4()}` - } - - jest - .spyOn(authServiceClient.tenant, 'create') - .mockImplementationOnce(async () => undefined) - - const tenant = await tenantService.create(originalTenantInfo) - assert(!isTenantError(tenant)) - - const updatedTenantInfo = { - id: tenant.id, - walletAddressPrefix: `${config.openPaymentsUrl}/${v4()}` - } - - 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( - originalTenantInfo.walletAddressPrefix - ) - }) - test('Cannot update deleted tenant', async (): Promise => { const originalSecret = 'test-secret' const dbTenant = await Tenant.query(knex).insertAndFetch({ @@ -405,7 +406,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') @@ -442,7 +444,7 @@ describe('Tenant Service', (): void => { const dbTenant = await tenantService.get(tenant.id) assert(!isTenantError(dbTenant)) - expect(dbTenant?.walletAddressPrefix).toBeNull() + expect(dbTenant?.walletAddressPrefix).toEqual(tenant.walletAddressPrefix) }) }) @@ -453,7 +455,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 @@ -486,7 +489,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 @@ -535,7 +539,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 f3cd34297e..c99b040ac9 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' @@ -115,7 +115,7 @@ async function createTenant( throw TenantError.InvalidTenantInput } - const tenant = await Tenant.query(trx).insertAndFetch({ + let tenant = await Tenant.query(trx).insertAndFetch({ id, email, publicName, @@ -176,6 +176,7 @@ async function createTenant( return tenant } catch (err) { await trx.rollback() + if (err instanceof UniqueViolationError) return TenantError.DuplicateWalletAddressPrefix if (isTenantError(err)) return err throw err } @@ -232,13 +233,11 @@ async function updateTenant( idpSecret }) } - - if (!tenant.walletAddressPrefix && walletAddressPrefix) { + + if (walletAddressPrefix) { tenant = await tenant.$query(trx).patchAndFetch({ - walletAddressPrefix: walletAddressPrefix.toLowerCase() + walletAddressPrefix }) - } else if (tenant.walletAddressPrefix && walletAddressPrefix) { - throw TenantError.InvalidTenantInput } await trx.commit() diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts index ed8ace484c..360e3fa4b8 100644 --- a/packages/backend/src/tenants/settings/service.test.ts +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -57,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 diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts index 9dc7ae8b18..0c95abf06e 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -64,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() } } @@ -83,6 +84,7 @@ export async function createTenant( publicName: faker.company.name(), idpConsentUrl: faker.internet.url(), idpSecret: 'test-idp-secret', + walletAddressPrefix: faker.internet.url(), ...options }) diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index f4fa1c3de8..e9da722311 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -40,18 +40,11 @@ export async function createWalletAddress( assert.ok(tenantToUse) let baseWalletAddressUrl = new URL( - options.address || `https://${faker.internet.domainName()}` + options.address || tenantToUse.walletAddressPrefix ) - if (!tenantToUse.walletAddressPrefix && tenantToUse.id) { - const updatedTenant = await tenantService.update({ - id: tenantToUse.id, - walletAddressPrefix: baseWalletAddressUrl.origin - }) - assert(!isTenantError(updatedTenant)) - } else { - baseWalletAddressUrl = new URL(tenantToUse.walletAddressPrefix) - } + console.log('prefix=', tenantToUse.walletAddressPrefix) + console.log('input=', `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay`) const walletAddressOrError = (await walletAddressService.create({ ...options, @@ -61,7 +54,7 @@ export async function createWalletAddress( tenantId: tenantToUse.id, address: options.address || - `${baseWalletAddressUrl.origin}/${v4()}/.well-known/pay` + `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index fd9f605eff..40edc74c97 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -402,7 +402,7 @@ export type CreateTenantInput = { /** Initial settings for tenant. */ settings?: InputMaybe>; /** Prefix for all wallet addresses belonging to this tenant. */ - walletAddressPrefix?: InputMaybe; + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index 7339f4199b..8a288713ed 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -160,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(), - walletAddressPrefix: z.string().url().or(z.literal('')).optional(), + walletAddressPrefix: z.string().url().or(z.literal('')), ilpAddress: z.string().optional() }) diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index e51068c733..fef9586b9b 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -402,7 +402,7 @@ export type CreateTenantInput = { /** Initial settings for tenant. */ settings?: InputMaybe>; /** Prefix for all wallet addresses belonging to this tenant. */ - walletAddressPrefix?: InputMaybe; + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index e51068c733..fef9586b9b 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -402,7 +402,7 @@ export type CreateTenantInput = { /** Initial settings for tenant. */ settings?: InputMaybe>; /** Prefix for all wallet addresses belonging to this tenant. */ - walletAddressPrefix?: InputMaybe; + walletAddressPrefix: Scalars['String']['input']; }; export type CreateTenantSettingsInput = { From 0cad2e9af276a16ce310ae5ca369559a5bb45ccf Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 10 Oct 2025 14:40:23 -0700 Subject: [PATCH 3/9] feat(backend): require wallet address prefix on tenant creation --- .../open_payments/wallet_address/errors.ts | 9 ++-- .../wallet_address/service.test.ts | 50 ++----------------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/packages/backend/src/open_payments/wallet_address/errors.ts b/packages/backend/src/open_payments/wallet_address/errors.ts index 03d672762c..ad548e0cd6 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', + UnknownTenant = 'UnknownTenant' } // 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.UnknownTenant]: GraphQLErrorCode.BadUserInput } export const errorToMessage: { @@ -27,5 +29,6 @@ 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.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 a12f481ff1..813dbd7190 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -94,57 +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, { - walletAddressPrefix: tenantSettingUrl - }) - const { id: tempAssetId } = await createAsset(deps, { - tenantId: tempTenant.id - }) - - let expected: string - if (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.WalletAddressPrefixNotFound) + ).toEqual(WalletAddressError.UnknownTenant) }) test('should return InvalidUrl error if wallet address URL does not start with tenant wallet address URL', async (): Promise => { From bb1c42c2831b1c5a108c48ef68fcd4b9c53867ea Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 10 Oct 2025 14:47:36 -0700 Subject: [PATCH 4/9] chore: formatting --- .../migrations/20250930210751_add_prefix_to_tenant.js | 8 ++++++-- .../backend/src/graphql/resolvers/wallet_address.test.ts | 6 ++---- .../backend/src/open_payments/wallet_address/service.ts | 2 +- packages/backend/src/tenants/service.ts | 7 ++++--- packages/backend/src/tests/walletAddress.ts | 7 ++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js index 2d58748daf..15be975f9a 100644 --- a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js +++ b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js @@ -13,10 +13,14 @@ exports.up = function (knex) { ) }) .then(() => { - return knex.raw(`DELETE FROM "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'`) + return knex.raw( + `DELETE FROM "tenantSettings" WHERE "key" = 'WALLET_ADDRESS_URL'` + ) }) .then(() => { - return knex.raw(`UPDATE "tenants" SET "walletAddressPrefix" = '${process.env.OPEN_PAYMENTS_URL}/' || gen_random_uuid() WHERE "walletAddressPrefix" IS NULL`) + 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) => { diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index bc27e3435d..bc525c2a4c 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -17,7 +17,7 @@ import { IocContract } from '@adonisjs/fold' import { AppServices } from '../../app' import { Asset } from '../../asset/model' import { initIocContainer } from '../..' -import { Config, IAppConfig } from '../../config/app' +import { Config } from '../../config/app' import { truncateTables } from '../../tests/tableManager' import { WalletAddressError, @@ -88,9 +88,7 @@ describe('Wallet Address Resolvers', (): void => { await appContainer.shutdown() }) - beforeEach(async () => { - - }) + beforeEach(async () => {}) describe('Create Wallet Address', (): void => { let asset: Asset diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index 7ff0ea94fa..1ad69a77b8 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -180,7 +180,7 @@ async function createWalletAddressUrl( const tenant = await deps.tenantService.get(options.tenantId) if (!tenant) return WalletAddressError.UnknownTenant - let tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) + const tenantWalletAddressUrl = new URL(tenant.walletAddressPrefix) let tenantBaseUrl = tenantWalletAddressUrl.toString() if (!tenantWalletAddressUrl.pathname.endsWith('/')) { diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index c99b040ac9..643bc2d0cf 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -115,7 +115,7 @@ async function createTenant( throw TenantError.InvalidTenantInput } - let tenant = await Tenant.query(trx).insertAndFetch({ + const tenant = await Tenant.query(trx).insertAndFetch({ id, email, publicName, @@ -176,7 +176,8 @@ async function createTenant( return tenant } catch (err) { await trx.rollback() - if (err instanceof UniqueViolationError) return TenantError.DuplicateWalletAddressPrefix + if (err instanceof UniqueViolationError) + return TenantError.DuplicateWalletAddressPrefix if (isTenantError(err)) return err throw err } @@ -233,7 +234,7 @@ async function updateTenant( idpSecret }) } - + if (walletAddressPrefix) { tenant = await tenant.$query(trx).patchAndFetch({ walletAddressPrefix diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index e9da722311..5b994885f0 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -1,6 +1,5 @@ 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' @@ -13,7 +12,6 @@ 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 { isTenantError } from '../tenants/errors' import { v4 } from 'uuid' const nock = (global as unknown as { nock: typeof import('nock') }).nock @@ -39,7 +37,7 @@ export async function createWalletAddress( : await createTenant(deps) assert.ok(tenantToUse) - let baseWalletAddressUrl = new URL( + const baseWalletAddressUrl = new URL( options.address || tenantToUse.walletAddressPrefix ) @@ -53,8 +51,7 @@ export async function createWalletAddress( (await createAsset(deps, { tenantId: tenantToUse.id })).id, tenantId: tenantToUse.id, address: - options.address || - `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay` + options.address || `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) From 9eb0b67af3e0c0307e5c562bd5f81bcd60bc5274 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 10 Oct 2025 15:50:31 -0700 Subject: [PATCH 5/9] fix: handle new gql schema properly in frontend --- .../generated/graphql.ts | 4 +- .../20250930210751_add_prefix_to_tenant.js | 5 ++ .../src/graphql/generated/graphql.schema.json | 10 ++- .../backend/src/graphql/generated/graphql.ts | 4 +- packages/backend/src/graphql/schema.graphql | 2 +- .../frontend/app/components/ui/Select.tsx | 6 +- packages/frontend/app/generated/graphql.ts | 8 +-- .../frontend/app/lib/api/tenant.server.ts | 1 + .../app/routes/wallet-addresses.create.tsx | 72 ++++++++----------- packages/frontend/app/styles/tailwind.css | 6 +- .../src/generated/graphql.ts | 4 +- test/test-lib/src/generated/graphql.ts | 4 +- 12 files changed, 64 insertions(+), 62 deletions(-) diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index fef9586b9b..416d0b0cf6 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -1541,7 +1541,7 @@ export type Tenant = Model & { /** List of settings for the tenant. */ settings: Array; /** Prefix for wallet addresses belonging to this tenant. */ - walletAddressPrefix?: Maybe; + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -2687,7 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; - walletAddressPrefix?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js index 15be975f9a..9365191bbb 100644 --- a/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js +++ b/packages/backend/migrations/20250930210751_add_prefix_to_tenant.js @@ -17,6 +17,11 @@ exports.up = function (knex) { `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` diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index f6a1809330..0a99432d61 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -8809,9 +8809,13 @@ "description": "Prefix for wallet addresses belonging to this tenant.", "args": [], "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "isDeprecated": false, "deprecationReason": null diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index fef9586b9b..416d0b0cf6 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -1541,7 +1541,7 @@ export type Tenant = Model & { /** List of settings for the tenant. */ settings: Array; /** Prefix for wallet addresses belonging to this tenant. */ - walletAddressPrefix?: Maybe; + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -2687,7 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; - walletAddressPrefix?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 2e5ebf79b2..ea04b0ccf2 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1642,7 +1642,7 @@ type Tenant implements Model { "Public name for the tenant." publicName: String "Prefix for wallet addresses belonging to this tenant." - walletAddressPrefix: String + walletAddressPrefix: String! "The date and time that this tenant was created." createdAt: String! "The date and time that this tenant was deleted." 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 40edc74c97..15af70211d 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -1541,7 +1541,7 @@ export type Tenant = Model & { /** List of settings for the tenant. */ settings: Array; /** Prefix for wallet addresses belonging to this tenant. */ - walletAddressPrefix?: Maybe; + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -2687,7 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; - walletAddressPrefix?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -3086,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; @@ -3114,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, walletAddressPrefix?: 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 004fb6c119..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 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 fef9586b9b..416d0b0cf6 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -1541,7 +1541,7 @@ export type Tenant = Model & { /** List of settings for the tenant. */ settings: Array; /** Prefix for wallet addresses belonging to this tenant. */ - walletAddressPrefix?: Maybe; + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -2687,7 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; - walletAddressPrefix?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index fef9586b9b..416d0b0cf6 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -1541,7 +1541,7 @@ export type Tenant = Model & { /** List of settings for the tenant. */ settings: Array; /** Prefix for wallet addresses belonging to this tenant. */ - walletAddressPrefix?: Maybe; + walletAddressPrefix: Scalars['String']['output']; }; export type TenantEdge = { @@ -2687,7 +2687,7 @@ export type TenantResolvers, ParentType, ContextType>; publicName?: Resolver, ParentType, ContextType>; settings?: Resolver, ParentType, ContextType>; - walletAddressPrefix?: Resolver, ParentType, ContextType>; + walletAddressPrefix?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; From 5153d9f96f0a14bb3c3508f2dd4ece460a12c1f8 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 14 Oct 2025 10:51:37 -0700 Subject: [PATCH 6/9] fix: cleanup --- bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru | 2 +- packages/backend/jest.env.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru index f30c8f3727..1782f0533f 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru @@ -32,7 +32,7 @@ body:graphql:vars { "apiSecret": "test-secret", "idpConsentUrl": "https://example.com/consent", "idpSecret": "test-idp-secret", - "walletAddressPrefix": "https://google.com" + "walletAddressPrefix": "https://cloud-nine-wallet-backend" } } } diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 185094512f..fabc9b0748 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -1,4 +1,4 @@ -process.env.LOG_LEVEL = 'info' +process.env.LOG_LEVEL = 'silent' process.env.INSTANCE_NAME = 'Rafiki' process.env.KEY_ID = 'myKey' process.env.OPEN_PAYMENTS_URL = 'https://127.0.0.1:3000' From e6c148a8cc22f00cb2cf83ccf8d001d6b1c1c89d Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 14 Oct 2025 11:52:54 -0700 Subject: [PATCH 7/9] fix: more test fixes --- .../graphql/resolvers/tenant_settings.test.ts | 66 ------------------- packages/backend/src/tenants/service.test.ts | 3 + .../src/tenants/settings/service.test.ts | 45 ------------- .../backend/src/tenants/settings/service.ts | 24 ------- packages/backend/src/tests/walletAddress.ts | 3 - 5 files changed, 3 insertions(+), 138 deletions(-) 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/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index eba06c8837..4886b6bfe5 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -119,6 +119,9 @@ describe('Tenant Service', (): void => { 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()}` }) diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts index 360e3fa4b8..e341bc4758 100644 --- a/packages/backend/src/tenants/settings/service.test.ts +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -283,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', () => { @@ -530,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 => { diff --git a/packages/backend/src/tenants/settings/service.ts b/packages/backend/src/tenants/settings/service.ts index b65fc9b644..c7b3489507 100644 --- a/packages/backend/src/tenants/settings/service.ts +++ b/packages/backend/src/tenants/settings/service.ts @@ -118,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') @@ -150,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 diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index 5b994885f0..4602461f1e 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -41,9 +41,6 @@ export async function createWalletAddress( options.address || tenantToUse.walletAddressPrefix ) - console.log('prefix=', tenantToUse.walletAddressPrefix) - console.log('input=', `${baseWalletAddressUrl.href}/${v4()}/.well-known/pay`) - const walletAddressOrError = (await walletAddressService.create({ ...options, assetId: From 94a0812275e47e15360971f7bd6df55132aa6fbf Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 14 Oct 2025 12:17:21 -0700 Subject: [PATCH 8/9] fix: typecheck --- packages/frontend/app/routes/peers.create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} /> ) : (