Browse Source

Extend MCP access to support view permission

pull/8112/head
Thomas Kaul 5 days ago
parent
commit
b6d127ce65
  1. 2
      README.md
  2. 107
      apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts
  3. 44
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 36
      apps/api/src/app/endpoints/mcp/mcp.service.spec.ts
  5. 34
      apps/api/src/app/endpoints/mcp/mcp.service.ts
  6. 178
      apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts
  7. 220
      apps/api/src/services/portfolio-table/portfolio-table.service.ts
  8. 16
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 2
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  10. 37
      libs/common/src/lib/scopes.spec.ts
  11. 9
      libs/common/src/lib/scopes.ts

2
README.md

@ -407,7 +407,7 @@ The _Model Context Protocol_ (MCP) server lets an AI client read your portfolio
- Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin. - Set `ROOT_URL` to the public URL of your instance if a client calls the endpoint from a browser page. The host name of `ROOT_URL` is the only accepted origin.
- Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token - Grant an access of the type _MCP_ in _My Ghostfolio_ under _Access_ and copy its token
An _MCP_ access has (restricted) read scopes and never reads the monetary values. Grant the _Restricted view and manage_ permission to let the client also import activities. An _MCP_ access with the _Restricted view_ permission reads the portfolio without the monetary values. Grant the _View_ permission to let the client also read the monetary values. Grant the _Restricted view and manage_ or the _View and manage_ permission to let the client also import activities.
### Connect a client ### Connect a client

107
apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts

@ -8,6 +8,10 @@ import {
Scope, Scope,
scopes scopes
} from '@ghostfolio/common/scopes'; } from '@ghostfolio/common/scopes';
import type {
AccessLevel,
ImpersonationContext
} from '@ghostfolio/common/types';
import { import {
EXCEPTION_FILTERS_METADATA, EXCEPTION_FILTERS_METADATA,
@ -23,6 +27,7 @@ import {
} from '@rekog/mcp-nest'; } from '@rekog/mcp-nest';
import { GhostfolioMcpController } from './mcp.controller'; import { GhostfolioMcpController } from './mcp.controller';
import { McpService } from './mcp.service';
/** /**
* Gives the metadata which a decorator sets on the method of a tool. The * Gives the metadata which a decorator sets on the method of a tool. The
@ -83,6 +88,48 @@ function getNamesOfListedTools(request: unknown) {
}); });
} }
/**
* Gives for each tool which reads the portfolio whether the controller asks
* the service for the monetary values
*/
async function getWithValuesOfReadTools(accessLevel: AccessLevel) {
const mcpService = {
getAccounts: jest.fn(),
getActivities: jest.fn(),
getPerformance: jest.fn(),
getPortfolio: jest.fn()
};
const controller = new GhostfolioMcpController(
mcpService as unknown as McpService
);
const impersonationContext = {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel(accessLevel),
type: 'MCP'
}),
userId: 'user-id',
userSettings: { baseCurrency: 'CHF' }
} as ImpersonationContext;
await controller.getAccounts(impersonationContext, {});
await controller.getActivities(
impersonationContext,
{} as Parameters<GhostfolioMcpController['getActivities']>[1]
);
await controller.getPerformance(
impersonationContext,
{} as Parameters<GhostfolioMcpController['getPerformance']>[1]
);
await controller.getPortfolio(impersonationContext);
return Object.values(mcpService).map((method) => {
return (method.mock.calls[0][0] as { withValues: boolean }).withValues;
});
}
describe('GhostfolioMcpController', () => { describe('GhostfolioMcpController', () => {
// A tool without the decorator of the scope would be open to every access, // A tool without the decorator of the scope would be open to every access,
// hence a new tool has to declare its scope // hence a new tool has to declare its scope
@ -163,6 +210,66 @@ describe('GhostfolioMcpController', () => {
]); ]);
}); });
it('Lists only the tools to read for an access with the permission "View"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('READ'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-performance',
'get-portfolio',
'get-watchlist'
]);
});
it('Lists every tool for an access with the permission "View and manage"', () => {
expect(
getNamesOfListedTools({
impersonationOfBearerToken: {
isActive: true,
scopes: getScopesOfAccess({
scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'),
type: 'MCP'
})
}
})
).toEqual([
'get-accounts',
'get-activities',
'get-performance',
'get-portfolio',
'get-watchlist',
'import-activities',
'search-asset-profiles'
]);
});
it('Gives the monetary values for an access with the permission "View"', async () => {
expect(await getWithValuesOfReadTools('READ')).toEqual([
true,
true,
true,
true
]);
});
it('Gives no monetary values for an access with the permission "Restricted view"', async () => {
expect(await getWithValuesOfReadTools('READ_RESTRICTED')).toEqual([
false,
false,
false,
false
]);
});
it('Lists no tool for an inactive access', () => { it('Lists no tool for an inactive access', () => {
expect( expect(
getNamesOfListedTools({ getNamesOfListedTools({

44
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -3,7 +3,7 @@ import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope
import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter';
import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service';
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type { ImpersonationContext } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { UseFilters } from '@nestjs/common'; import { UseFilters } from '@nestjs/common';
@ -35,15 +35,22 @@ export class GhostfolioMcpController {
}, },
description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join( description: `Gives the accounts of the portfolio with these columns: ${PortfolioTableService.getAccountsTableColumnNames().join(
', ' ', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getAccountsTableValueColumnNames().join(
', '
)}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`, )}. The allocation in percentage is relative to the accounts of the result, hence the parameters change it.`,
name: 'get-accounts', name: 'get-accounts',
parameters: GET_ACCOUNTS_PARAMETERS parameters: GET_ACCOUNTS_PARAMETERS
}) })
public async getAccounts( public async getAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS> @Payload() parameters: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) { ) {
return this.mcpService.getAccounts({ ...parameters, userId }); return this.mcpService.getAccounts({
...parameters,
userId,
withValues: hasScope(impersonationScopes, scopes.portfolioReadValues)
});
} }
@RequiresScopeOfAccess(scopes.activityRead) @RequiresScopeOfAccess(scopes.activityRead)
@ -55,18 +62,22 @@ export class GhostfolioMcpController {
}, },
description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join( description: `Gives the activities of the portfolio, the most recent first, with these columns: ${PortfolioTableService.getActivitiesTableColumnNames().join(
', ' ', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getActivitiesTableValueColumnNames().join(
', '
)}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`, )}. At most ${MCP_MAX_ACTIVITIES} activities are given per call, hence narrow the result with the parameters or get the further activities with the skip parameter.`,
name: 'get-activities', name: 'get-activities',
parameters: GET_ACTIVITIES_PARAMETERS parameters: GET_ACTIVITIES_PARAMETERS
}) })
public async getActivities( public async getActivities(
@Impersonation() { userId, userSettings }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId, userSettings }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS> @Payload() parameters: z.infer<typeof GET_ACTIVITIES_PARAMETERS>
) { ) {
return this.mcpService.getActivities({ return this.mcpService.getActivities({
...parameters, ...parameters,
userId, userId,
userCurrency: userSettings.baseCurrency userCurrency: userSettings.baseCurrency,
withValues: hasScope(impersonationScopes, scopes.portfolioReadValues)
}); });
} }
@ -79,15 +90,22 @@ export class GhostfolioMcpController {
}, },
description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join( description: `Gives the performance of the portfolio in the date range with these columns: ${PortfolioTableService.getPerformanceTableColumnNames().join(
', ' ', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getPerformanceTableValueColumnNames().join(
', '
)}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`, )}. The asset performance excludes the effect of the exchange rates, the currency performance is that effect, and the net performance is the sum of both in the base currency of the user. Each performance is the return on average investment (ROAI) and includes the dividends (total return). The accounts and the activities which are excluded from analysis are not part of the performance. The parameters limit the performance to the holdings of the accounts, of the asset classes or of the asset profile.`,
name: 'get-performance', name: 'get-performance',
parameters: GET_PERFORMANCE_PARAMETERS parameters: GET_PERFORMANCE_PARAMETERS
}) })
public async getPerformance( public async getPerformance(
@Impersonation() { userId }: ImpersonationContext, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Payload() parameters: z.infer<typeof GET_PERFORMANCE_PARAMETERS> @Payload() parameters: z.infer<typeof GET_PERFORMANCE_PARAMETERS>
) { ) {
return this.mcpService.getPerformance({ ...parameters, userId }); return this.mcpService.getPerformance({
...parameters,
userId,
withValues: hasScope(impersonationScopes, scopes.portfolioReadValues)
});
} }
@RequiresScopeOfAccess(scopes.portfolioRead) @RequiresScopeOfAccess(scopes.portfolioRead)
@ -99,11 +117,19 @@ export class GhostfolioMcpController {
}, },
description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join( description: `Gives the holdings of the portfolio with these columns: ${PortfolioTableService.getHoldingsTableColumnNames().join(
', ' ', '
)}. If the access reads the monetary values, these columns are given in addition: ${PortfolioTableService.getHoldingsTableValueColumnNames().join(
', '
)}.`, )}.`,
name: 'get-portfolio' name: 'get-portfolio'
}) })
public async getPortfolio(@Impersonation() { userId }: ImpersonationContext) { public async getPortfolio(
return this.mcpService.getPortfolio({ userId }); @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext
) {
return this.mcpService.getPortfolio({
userId,
withValues: hasScope(impersonationScopes, scopes.portfolioReadValues)
});
} }
@RequiresScopeOfAccess(scopes.watchlistRead) @RequiresScopeOfAccess(scopes.watchlistRead)

36
apps/api/src/app/endpoints/mcp/mcp.service.spec.ts

@ -169,6 +169,14 @@ describe('McpService', () => {
userId userId
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getAccounts({ userId, withValues: true });
expect(portfolioTableService.getAccountsTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getActivities', () => { describe('getActivities', () => {
@ -272,6 +280,14 @@ describe('McpService', () => {
types: [ActivityType.BUY] types: [ActivityType.BUY]
}); });
}); });
it('Gives the values if the access reads them', async () => {
await getActivities({ withValues: true });
expect(portfolioTableService.getActivitiesTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getPerformance', () => { describe('getPerformance', () => {
@ -350,6 +366,18 @@ describe('McpService', () => {
dateRange: '2024' dateRange: '2024'
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getPerformance({
userId,
range: '2024',
withValues: true
});
expect(portfolioTableService.getPerformanceTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getPortfolio', () => { describe('getPortfolio', () => {
@ -364,6 +392,14 @@ describe('McpService', () => {
withDataSource: true withDataSource: true
}); });
}); });
it('Gives the values if the access reads them', async () => {
await mcpService.getPortfolio({ userId, withValues: true });
expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith(
expect.objectContaining({ withValues: true })
);
});
}); });
describe('getWatchlist', () => { describe('getWatchlist', () => {

34
apps/api/src/app/endpoints/mcp/mcp.service.ts

@ -39,8 +39,12 @@ export class McpService {
accountIds, accountIds,
assetClasses, assetClasses,
holding, holding,
userId userId,
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & { userId: string }) { withValues
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS> & {
userId: string;
withValues?: boolean;
}) {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
...this.getHoldingFilterParameters({ holding }), ...this.getHoldingFilterParameters({ holding }),
filterByAccounts: accountIds, filterByAccounts: accountIds,
@ -49,7 +53,8 @@ export class McpService {
const table = await this.portfolioTableService.getAccountsTable({ const table = await this.portfolioTableService.getAccountsTable({
filters, filters,
userId userId,
withValues
}); });
return this.getTextResult(table); return this.getTextResult(table);
@ -63,10 +68,12 @@ export class McpService {
skip, skip,
take, take,
userCurrency, userCurrency,
userId userId,
withValues
}: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & { }: z.infer<typeof GET_ACTIVITIES_PARAMETERS> & {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withValues?: boolean;
}) { }) {
let endDate: Date | undefined; let endDate: Date | undefined;
let startDate: Date | undefined; let startDate: Date | undefined;
@ -90,6 +97,7 @@ export class McpService {
take, take,
userCurrency, userCurrency,
userId, userId,
withValues,
types: activityTypes types: activityTypes
}); });
@ -101,8 +109,12 @@ export class McpService {
assetClasses, assetClasses,
holding, holding,
range, range,
userId userId,
}: z.infer<typeof GET_PERFORMANCE_PARAMETERS> & { userId: string }) { withValues
}: z.infer<typeof GET_PERFORMANCE_PARAMETERS> & {
userId: string;
withValues?: boolean;
}) {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
...this.getHoldingFilterParameters({ holding }), ...this.getHoldingFilterParameters({ holding }),
filterByAccounts: accountIds, filterByAccounts: accountIds,
@ -112,15 +124,23 @@ export class McpService {
const table = await this.portfolioTableService.getPerformanceTable({ const table = await this.portfolioTableService.getPerformanceTable({
filters, filters,
userId, userId,
withValues,
dateRange: range dateRange: range
}); });
return this.getTextResult(table); return this.getTextResult(table);
} }
public async getPortfolio({ userId }: { userId: string }) { public async getPortfolio({
userId,
withValues
}: {
userId: string;
withValues?: boolean;
}) {
const table = await this.portfolioTableService.getHoldingsTable({ const table = await this.portfolioTableService.getHoldingsTable({
userId, userId,
withValues,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
withDataSource: true withDataSource: true
}); });

178
apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts

@ -84,7 +84,8 @@ function createAccount({
currency: 'CHF', currency: 'CHF',
platform: { name: 'Platform A' }, platform: { name: 'Platform A' },
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [], tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
value: 2000 value: 2000,
valueInBaseCurrency: 1800
} as unknown as AccountWithValue; } as unknown as AccountWithValue;
} }
@ -105,8 +106,11 @@ function createActivity({
}, },
currency: 'CHF', currency: 'CHF',
date: new Date('2024-01-01'), date: new Date('2024-01-01'),
fee: 1.5,
quantity: 5,
type: 'BUY', type: 'BUY',
unitPrice: 100 unitPrice: 100,
valueInBaseCurrency: 512.3456
} as unknown as Activity; } as unknown as Activity;
} }
@ -136,6 +140,7 @@ function createHolding({
}, },
dateOfFirstActivity: new Date('2024-01-01'), dateOfFirstActivity: new Date('2024-01-01'),
grossPerformance: 100, grossPerformance: 100,
marketPrice: 400,
netPerformance: 90, netPerformance: 90,
quantity: 5, quantity: 5,
valueInBaseCurrency: 2000 valueInBaseCurrency: 2000
@ -143,20 +148,24 @@ function createHolding({
} }
function createPerformance({ function createPerformance({
netPerformance = 200,
netPerformancePercentage = 0.1, netPerformancePercentage = 0.1,
netPerformancePercentageWithCurrencyEffect = 0.15 netPerformancePercentageWithCurrencyEffect = 0.15,
netPerformanceWithCurrencyEffect = 300
}: { }: {
netPerformance?: number;
netPerformancePercentage?: number; netPerformancePercentage?: number;
netPerformancePercentageWithCurrencyEffect?: number; netPerformancePercentageWithCurrencyEffect?: number;
netPerformanceWithCurrencyEffect?: number;
} = {}): PortfolioPerformanceResponse['performance'] { } = {}): PortfolioPerformanceResponse['performance'] {
return { return {
netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
currentNetWorth: 3000, currentNetWorth: 3000,
currentValueInBaseCurrency: 2000, currentValueInBaseCurrency: 2000,
dividendInBaseCurrency: 50, dividendInBaseCurrency: 50,
netPerformance: 200,
netPerformanceWithCurrencyEffect: 300,
totalInvestment: 1700, totalInvestment: 1700,
totalInvestmentValueWithCurrencyEffect: 1700 totalInvestmentValueWithCurrencyEffect: 1700
}; };
@ -246,9 +255,8 @@ function createPortfolioTableService({
} }
describe('PortfolioTableService', () => { describe('PortfolioTableService', () => {
// The tools of the model context protocol are the only callers, and an // A column with a monetary value is given only with the values, hence the
// access of that type never grants the scope to read the monetary values, // names of these columns are given separately
// hence no table has a column with such a value
describe('getAccountsTableColumnNames', () => { describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([ expect(PortfolioTableService.getAccountsTableColumnNames()).toEqual([
@ -263,6 +271,15 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getAccountsTableValueColumnNames', () => {
it('gives the columns with a monetary value', () => {
expect(PortfolioTableService.getAccountsTableValueColumnNames()).toEqual([
'Balance',
'Value in Base Currency'
]);
});
});
describe('getActivitiesTableColumnNames', () => { describe('getActivitiesTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([ expect(PortfolioTableService.getActivitiesTableColumnNames()).toEqual([
@ -278,6 +295,14 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getActivitiesTableValueColumnNames', () => {
it('gives the columns with a quantity or with a monetary value', () => {
expect(
PortfolioTableService.getActivitiesTableValueColumnNames()
).toEqual(['Quantity', 'Fee', 'Value in Base Currency']);
});
});
describe('getHoldingsTableColumnNames', () => { describe('getHoldingsTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([
@ -294,6 +319,16 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getHoldingsTableValueColumnNames', () => {
it('gives the columns with a quantity or with a monetary value', () => {
expect(PortfolioTableService.getHoldingsTableValueColumnNames()).toEqual([
'Quantity',
'Market Price',
'Value in Base Currency'
]);
});
});
describe('getPerformanceTableColumnNames', () => { describe('getPerformanceTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([ expect(PortfolioTableService.getPerformanceTableColumnNames()).toEqual([
@ -304,6 +339,19 @@ describe('PortfolioTableService', () => {
}); });
}); });
describe('getPerformanceTableValueColumnNames', () => {
it('gives the columns with a monetary value', () => {
expect(
PortfolioTableService.getPerformanceTableValueColumnNames()
).toEqual([
'Current Value in Base Currency',
'Asset Performance in Base Currency',
'Currency Performance in Base Currency',
'Net Performance in Base Currency'
]);
});
});
describe('getWatchlistTableColumnNames', () => { describe('getWatchlistTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([
@ -331,9 +379,27 @@ describe('PortfolioTableService', () => {
expect(result).not.toContain('Cash Balance'); expect(result).not.toContain('Cash Balance');
expect(result).not.toContain('1000'); expect(result).not.toContain('1000');
expect(result).not.toContain('1800');
expect(result).not.toContain('2000'); expect(result).not.toContain('2000');
}); });
it('gives the balance and the value with the values', async () => {
const portfolioTableService = createPortfolioTableService({
accounts: [createAccount()]
});
const result = await portfolioTableService.getAccountsTable({
userId: 'user-id',
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| account-a-id');
});
expect(row).toMatch(/\| false \| 1000 \| 1800\.00 \|$/);
});
// The accountIds parameter of the tool takes the identifiers, hence the // The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them // table has to give them
it('gives the identifier of an account', async () => { it('gives the identifier of an account', async () => {
@ -405,6 +471,40 @@ describe('PortfolioTableService', () => {
expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`);
expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`);
}); });
it('gives no quantity, no fee and no value by default', async () => {
const result = await createPortfolioTableService({
activities: [createActivity()]
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id'
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(result).not.toContain('Quantity');
expect(row).toMatch(/\| 100 \| Account A \|$/);
});
it('gives the quantity, the fee and the value with the values', async () => {
const result = await createPortfolioTableService({
activities: [createActivity()]
}).getActivitiesTable({
take: 50,
userCurrency: 'CHF',
userId: 'user-id',
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 2024-01-01');
});
expect(row).toMatch(/\| Account A \| 5 \| 1\.5 \| 512\.35 \|$/);
});
}); });
describe('getHoldingsTable', () => { describe('getHoldingsTable', () => {
@ -412,10 +512,12 @@ describe('PortfolioTableService', () => {
holdings: PortfolioPosition[], holdings: PortfolioPosition[],
{ {
configuration, configuration,
withDataSource withDataSource,
withValues
}: { }: {
configuration?: Record<string, unknown>; configuration?: Record<string, unknown>;
withDataSource?: boolean; withDataSource?: boolean;
withValues?: boolean;
} = {} } = {}
) { ) {
return createPortfolioTableService({ return createPortfolioTableService({
@ -423,6 +525,7 @@ describe('PortfolioTableService', () => {
holdings holdings
}).getHoldingsTable({ }).getHoldingsTable({
withDataSource, withDataSource,
withValues,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
userId: 'user-id' userId: 'user-id'
}); });
@ -516,13 +619,38 @@ describe('PortfolioTableService', () => {
expect(firstRow).toContain('AAPL'); expect(firstRow).toContain('AAPL');
expect(secondRow).toContain('MSFT'); expect(secondRow).toContain('MSFT');
}); });
it('gives no quantity and no value by default', async () => {
const result = await getHoldingsTable([createHolding()]);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(result).not.toContain('Quantity');
expect(row).toMatch(/\| 75\.000% \|$/);
});
it('gives the quantity, the market price and the value with the values', async () => {
const result = await getHoldingsTable([createHolding()], {
withValues: true
});
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| Name of AAPL');
});
expect(row).toMatch(/\| 75\.000% \| 5 \| 400 \| 2000\.00 \|$/);
});
}); });
describe('getPerformanceTable', () => { describe('getPerformanceTable', () => {
function getPerformanceTable( function getPerformanceTable(
parameters: Parameters<typeof createPortfolioTableService>[0] = {} parameters: Parameters<typeof createPortfolioTableService>[0] = {},
withValues?: boolean
) { ) {
return createPortfolioTableService(parameters).getPerformanceTable({ return createPortfolioTableService(parameters).getPerformanceTable({
withValues,
dateRange: 'ytd', dateRange: 'ytd',
userId: 'user-id' userId: 'user-id'
}); });
@ -565,6 +693,36 @@ describe('PortfolioTableService', () => {
} }
}); });
it('gives the current value and the performance in base currency with the values', async () => {
const result = await getPerformanceTable({}, true);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
expect(row).toBe(
'| 10.000% | 5.000% | 15.000% | 2000.00 | 200.00 | 100.00 | 300.00 |'
);
});
it('gives a currency performance in base currency of zero without a sign', async () => {
const result = await getPerformanceTable(
{
performance: createPerformance({
netPerformance: 200.000001,
netPerformanceWithCurrencyEffect: 200
})
},
true
);
const [row] = result.split('\n').filter((line) => {
return line.startsWith('| 10.000%');
});
expect(row).toMatch(/\| 200\.00 \| 0\.00 \| 200\.00 \|$/);
});
it('tells that no performance is found if the chart is empty', async () => { it('tells that no performance is found if the chart is empty', async () => {
const result = await getPerformanceTable({ chart: [] }); const result = await getPerformanceTable({ chart: [] });

220
apps/api/src/services/portfolio-table/portfolio-table.service.ts

@ -48,15 +48,21 @@ function getDataSourceColumnDefinition<T>(
}; };
} }
function getAmount(value: number) {
const amount = value.toFixed(2);
return Number.parseFloat(amount) === 0 ? (0).toFixed(2) : amount;
}
function getPercentage(value: number) { function getPercentage(value: number) {
return `${(value * 100).toFixed(3)}%`; return `${(value * 100).toFixed(3)}%`;
} }
/** /**
* Renders the accounts, the activities, the holdings and the performance of a * Renders the accounts, the activities, the holdings and the performance of a
* portfolio and the watchlist of its user as a markdown table. No table has a * portfolio and the watchlist of its user as a markdown table. A column with a
* column with a quantity or with a monetary value, except the unit price of an * quantity or with a monetary value, except the unit price of an activity, is
* activity. * given only with the values.
*/ */
@Injectable() @Injectable()
export class PortfolioTableService { export class PortfolioTableService {
@ -108,6 +114,24 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<AccountWithValue>[] =
[
{
align: 'right',
getValue: ({ balance }) => {
return balance.toString();
},
name: 'Balance'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity, Activity,
DataSourceTableContext DataSourceTableContext
@ -160,6 +184,33 @@ export class PortfolioTableService {
} }
]; ];
private static readonly ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<
Activity,
DataSourceTableContext
>[] = [
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
align: 'right',
getValue: ({ fee }) => {
return fee.toString();
},
name: 'Fee'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[ [
{ {
@ -221,6 +272,33 @@ export class PortfolioTableService {
} }
]; ];
private static readonly HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] =
[
{
align: 'right',
getValue: ({ quantity }) => {
return quantity.toString();
},
name: 'Quantity'
},
{
align: 'right',
getValue: ({ marketPrice }) => {
return marketPrice.toString();
},
name: 'Market Price'
},
{
align: 'right',
getValue: ({ valueInBaseCurrency }) => {
return valueInBaseCurrency === undefined
? ''
: getAmount(valueInBaseCurrency);
},
name: 'Value in Base Currency'
}
];
private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] = private static readonly PERFORMANCE_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
[ [
{ {
@ -256,6 +334,38 @@ export class PortfolioTableService {
} }
]; ];
private static readonly PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS: TableColumnDefinition<PortfolioPerformance>[] =
[
{
align: 'right',
getValue: ({ currentValueInBaseCurrency }) => {
return getAmount(currentValueInBaseCurrency);
},
name: 'Current Value in Base Currency'
},
{
align: 'right',
getValue: ({ netPerformance }) => {
return getAmount(netPerformance);
},
name: 'Asset Performance in Base Currency'
},
{
align: 'right',
getValue: ({ netPerformance, netPerformanceWithCurrencyEffect }) => {
return getAmount(netPerformanceWithCurrencyEffect - netPerformance);
},
name: 'Currency Performance in Base Currency'
},
{
align: 'right',
getValue: ({ netPerformanceWithCurrencyEffect }) => {
return getAmount(netPerformanceWithCurrencyEffect);
},
name: 'Net Performance in Base Currency'
}
];
private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition<
WatchlistResponse['watchlist'][number], WatchlistResponse['watchlist'][number],
DataSourceTableContext DataSourceTableContext
@ -326,6 +436,14 @@ export class PortfolioTableService {
); );
} }
public static getAccountsTableValueColumnNames() {
return PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getActivitiesTableColumnNames() { public static getActivitiesTableColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -334,6 +452,14 @@ export class PortfolioTableService {
); );
} }
public static getActivitiesTableValueColumnNames() {
return PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getHoldingsTableColumnNames() { public static getHoldingsTableColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -342,6 +468,14 @@ export class PortfolioTableService {
); );
} }
public static getHoldingsTableValueColumnNames() {
return PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getPerformanceTableColumnNames() { public static getPerformanceTableColumnNames() {
return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -350,6 +484,14 @@ export class PortfolioTableService {
); );
} }
public static getPerformanceTableValueColumnNames() {
return PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS.map(
({ name }) => {
return name;
}
);
}
public static getWatchlistTableColumnNames() { public static getWatchlistTableColumnNames() {
return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map( return PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS.map(
({ name }) => { ({ name }) => {
@ -360,10 +502,12 @@ export class PortfolioTableService {
public async getAccountsTable({ public async getAccountsTable({
filters, filters,
userId userId,
withValues = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
withValues?: boolean;
}) { }) {
const { accounts } = const { accounts } =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
@ -377,8 +521,13 @@ export class PortfolioTableService {
if (accounts.length > 0) { if (accounts.length > 0) {
accountsSection.push( accountsSection.push(
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.ACCOUNTS_TABLE_VALUE_COLUMN_DEFINITIONS
}),
rows: accounts rows: accounts
}) })
); );
@ -397,7 +546,8 @@ export class PortfolioTableService {
take, take,
types, types,
userCurrency, userCurrency,
userId userId,
withValues = false
}: { }: {
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
@ -407,6 +557,7 @@ export class PortfolioTableService {
types?: ActivityType[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withValues?: boolean;
}) { }) {
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
@ -437,8 +588,13 @@ export class PortfolioTableService {
activitiesSection.push( activitiesSection.push(
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.ACTIVITIES_TABLE_VALUE_COLUMN_DEFINITIONS
}),
context: { configurationService: this.configurationService }, context: { configurationService: this.configurationService },
rows: activities rows: activities
}) })
@ -452,12 +608,14 @@ export class PortfolioTableService {
filters, filters,
languageCode, languageCode,
userId, userId,
withDataSource = false withDataSource = false,
withValues = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
languageCode: string; languageCode: string;
userId: string; userId: string;
withDataSource?: boolean; withDataSource?: boolean;
withValues?: boolean;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
@ -484,12 +642,15 @@ export class PortfolioTableService {
'## Holdings', '## Holdings',
'', '',
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( withValues,
({ name }) => { columnDefinitions:
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS,
} valueColumnDefinitions:
), PortfolioTableService.HOLDINGS_TABLE_VALUE_COLUMN_DEFINITIONS
}).filter(({ name }) => {
return withDataSource || name !== DATA_SOURCE_COLUMN_NAME;
}),
context: { context: {
assetClassTranslations, assetClassTranslations,
assetSubClassTranslations, assetSubClassTranslations,
@ -503,11 +664,13 @@ export class PortfolioTableService {
public async getPerformanceTable({ public async getPerformanceTable({
dateRange, dateRange,
filters, filters,
userId userId,
withValues = false
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
withValues?: boolean;
}) { }) {
const { chart, performance } = await this.portfolioService.getPerformance({ const { chart, performance } = await this.portfolioService.getPerformance({
dateRange, dateRange,
@ -520,8 +683,13 @@ export class PortfolioTableService {
if (chart?.length > 0) { if (chart?.length > 0) {
performanceSection.push( performanceSection.push(
await getMarkdownTable({ await getMarkdownTable({
columnDefinitions: columnDefinitions: this.getColumnDefinitions({
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS, withValues,
columnDefinitions:
PortfolioTableService.PERFORMANCE_TABLE_COLUMN_DEFINITIONS,
valueColumnDefinitions:
PortfolioTableService.PERFORMANCE_TABLE_VALUE_COLUMN_DEFINITIONS
}),
rows: [performance] rows: [performance]
}) })
); );
@ -587,6 +755,20 @@ export class PortfolioTableService {
return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`; return `${summary} Get the further activities by raising the skip parameter or narrow the result with the other parameters.`;
} }
private getColumnDefinitions<T>({
columnDefinitions,
valueColumnDefinitions,
withValues
}: {
columnDefinitions: T[];
valueColumnDefinitions: T[];
withValues: boolean;
}) {
return withValues
? [...columnDefinitions, ...valueColumnDefinitions]
: columnDefinitions;
}
private getEnumTranslations<T extends string>({ private getEnumTranslations<T extends string>({
id, id,
languageCode, languageCode,

16
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -7,6 +7,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
Scope, Scope,
canGrantRestrictedWriteAccess, canGrantRestrictedWriteAccess,
canGrantUnrestrictedReadAccess,
getAccessLevel, getAccessLevel,
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel, getScopesOfAccessLevel,
@ -139,6 +140,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return canGrantRestrictedWriteAccess({ type: this.accessType }); return canGrantRestrictedWriteAccess({ type: this.accessType });
} }
public get canGrantUnrestrictedReadAccess() {
return canGrantUnrestrictedReadAccess({ type: this.accessType });
}
public get canGrantWriteAccess() { public get canGrantWriteAccess() {
return this.hasExperimentalFeatures; return this.hasExperimentalFeatures;
} }
@ -202,13 +207,16 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
granteeUserIdControl?.setValue(null); granteeUserIdControl?.setValue(null);
} }
// Narrow the permission to the scopes which the type permits, because // Narrow the permission to the scopes which the type permits and drop
// an access which is not granted to a user never exposes the monetary // the monetary values, which have to be granted after the type
// values and a public access never changes data
this.accessForm.get('accessLevel')?.setValue( this.accessForm.get('accessLevel')?.setValue(
getAccessLevel( getAccessLevel(
getScopesOfAccess({ getScopesOfAccess({
scopes: getScopesOfAccessLevel(this.accessLevel), scopes: getScopesOfAccessLevel(this.accessLevel).filter(
(scope) => {
return scope !== scopes.portfolioReadValues;
}
),
type: accessType type: accessType
}) })
) )

2
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -92,7 +92,7 @@
/> />
</mat-option> </mat-option>
} }
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (canGrantUnrestrictedReadAccess) {
<mat-option value="READ"> <mat-option value="READ">
<gf-access-level-icon accessLevel="READ" /> <gf-access-level-icon accessLevel="READ" />
</mat-option> </mat-option>

37
libs/common/src/lib/scopes.spec.ts

@ -3,6 +3,7 @@ import {
SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS, SCOPES_OF_WRITE_ACCESS,
canGrantUnrestrictedReadAccess,
getAccessLevel, getAccessLevel,
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel, getScopesOfAccessLevel,
@ -239,12 +240,21 @@ describe('Scopes', () => {
expect(scopesOfAccess).toContain(scopes.watchlistRead); expect(scopesOfAccess).toContain(scopes.watchlistRead);
}); });
it('Cannot expose the monetary values', () => { it('Allows reading the monetary values', () => {
expect( expect(
getScopesOfAccess({ getScopesOfAccess({
scopes: [...SCOPES_OF_READ_ACCESS], scopes: [...SCOPES_OF_READ_ACCESS],
type: 'MCP' type: 'MCP'
}) })
).toContain(scopes.portfolioReadValues);
});
it('Without the scope to read the values', () => {
expect(
getScopesOfAccess({
scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS],
type: 'MCP'
})
).not.toContain(scopes.portfolioReadValues); ).not.toContain(scopes.portfolioReadValues);
}); });
@ -276,6 +286,31 @@ describe('Scopes', () => {
) )
).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE'); ).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE');
}); });
it('Keeps the access level to change the data with the monetary values', () => {
expect(
getAccessLevel(
getScopesOfAccess({
scopes: getScopesOfAccessLevel('CREATE_READ_UPDATE_DELETE'),
type: 'MCP'
})
)
).toEqual('CREATE_READ_UPDATE_DELETE');
});
});
describe('Can grant unrestricted read access', () => {
it('Model context protocol', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'MCP' })).toEqual(true);
});
it('Private', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'PRIVATE' })).toEqual(true);
});
it('Public', () => {
expect(canGrantUnrestrictedReadAccess({ type: 'PUBLIC' })).toEqual(false);
});
}); });
describe('Get scopes of own access', () => { describe('Get scopes of own access', () => {

9
libs/common/src/lib/scopes.ts

@ -68,7 +68,7 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] =
* monetary values. * monetary values.
*/ */
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = { const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
MCP: [...SCOPES_OF_READ_RESTRICTED_ACCESS, scopes.activityCreate], MCP: [...SCOPES_OF_READ_ACCESS, scopes.activityCreate],
PRIVATE: Object.values(scopes), PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS PUBLIC: SCOPES_OF_PUBLIC_ACCESS
}; };
@ -81,6 +81,13 @@ export function canGrantRestrictedWriteAccess({ type }: { type: AccessType }) {
return type === 'MCP'; return type === 'MCP';
} }
/**
* Access types which can expose the monetary values
*/
export function canGrantUnrestrictedReadAccess({ type }: { type: AccessType }) {
return SCOPES_OF_TYPE[type].includes(scopes.portfolioReadValues);
}
/** /**
* Access level which the scopes of an access grant * Access level which the scopes of an access grant
*/ */

Loading…
Cancel
Save