diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index 0c1cd8e4..a9b1e8d9 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -31,6 +31,60 @@ This table was created to guide and centralize the **environment variables** nec |LOG_LEVEL |`debug, info, warn, error, fatal` |'Minimum log level. Defaults to info' | |METRICS_ENABLED |`true or false` |'enable metric debug log' | |NODE_ENV|`production or development`|'Indicates if the app should be built for a production environment or not' +|DEPLOY_ENV |`local` |'Deployment environment whose backoffice feature flags are retrieved. Defaults to local'| +|BACKOFFICE_API_URL |`http://localhost:3010` |'Base URL of the backoffice API serving the feature flags. Empty disables the integration (local features only)'| +|BACKOFFICE_API_EMAIL | |'Backoffice service account email (read-only feature-flags role)'| +|BACKOFFICE_API_PASSWORD | |'Backoffice service account password. Secret — never commit'| +|BACKOFFICE_FLAGS_CACHE_TTL_MS |60000 |'How long retrieved flags are cached before re-fetching'| +|BACKOFFICE_HTTP_TIMEOUT_MS |2000 |'Timeout for each backoffice HTTP request'| + +### Backoffice feature flags + +When the `BACKOFFICE_*` variables are set, every feature flag configured on the +backoffice (e.g. `FLYOVER`, `UNION_BRIDGE`, `POWPEG`, `MAINTENANCE_MODE`) is +retrieved for the environment matching `DEPLOY_ENV` and merged into the +`/features` response under its lowercased key (e.g. `flyover`). New flags added +on the backoffice flow through without code changes. + +A boolean flag is served as `enabled`/`disabled`; a string, number or JSON flag +is served as it stands, so the backoffice can hold text (e.g. +`terms_and_conditions`) or structured configuration. Flags holding no value at +all (`null`, or none) are ignored and logged. A boolean flag never overwrites a +stored feature holding neither `enabled` nor `disabled` — the backoffice can +still replace that text by serving a value of its own. + +A non-boolean flag whose key extends a boolean flag's key sets a property on +that feature row instead, named after the camelCased remainder (e.g. +`WALLET_LEDGER_SUPPORTED_BROWSERS` sets `supportedBrowsers` of +`wallet_ledger`), so new properties defined on the backoffice need no code +changes either. `name`, `value` and `pairs` are reserved; a flag whose +remainder camelCases to one of them, or that matches no boolean flag, is +served as a flag of its own. + +The same retrieval asks for the backoffice providers (`include=providers`) and +merges each one the same way, under its lowercased key (e.g. `boltz`), carrying +the pairs it can serve in a nested `pairs` array: + +```json +{"name": "boltz", "value": "enabled", "pairs": [ + {"fromNetwork": "BITCOIN", "toNetwork": "ROOTSTOCK", + "fromToken": "BTC", "toToken": "RBTC", "enabled": true} +]} +``` + +A pair is nested only when both the pair and its provider are enabled, so a +disabled provider is served as `disabled` with an empty `pairs` array. A +provider needs a `key` and a boolean `enabled` to be served at all (others are +ignored and logged), and nothing else about it is exposed. A pair needs only +`enabled` and is served exactly as it arrives, so attributes added on the +backoffice reach `/features` without code changes. A payload carrying no +`providers` is served as flags only. + +Values are cached for `BACKOFFICE_FLAGS_CACHE_TTL_MS`; once expired, the stale +values keep being served while a refresh runs in the background, so only the +very first retrieval waits on the backoffice. On backoffice downtime the last +retrieved values are served (or the flags are simply omitted from `/features`), +and failed or invalid retrievals are logged. ##Example for .env.local.test file @@ -79,4 +133,11 @@ MAX_FEE_AMOUNT_ALLOWED=5000000 BURN_DUST_VALUE=2000 NODE_ENV=development + +# Backoffice feature flags (empty BACKOFFICE_API_URL disables the integration) +BACKOFFICE_API_URL='http://localhost:3010' +BACKOFFICE_API_EMAIL='2wp-api@example.com' +BACKOFFICE_API_PASSWORD='replace-with-the-service-account-password' +BACKOFFICE_FLAGS_CACHE_TTL_MS=60000 +BACKOFFICE_HTTP_TIMEOUT_MS=2000 ``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 27046642..424168bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "2wp-api", - "version": "4.0.1", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "2wp-api", - "version": "4.0.1", + "version": "4.1.0", "dependencies": { "@loopback/boot": "^8.0.13", "@loopback/core": "^7.0.12", @@ -37,7 +37,7 @@ "@types/big.js": "^6.1.2", "@types/bs58": "^4.0.1", "@types/nock": "^10.0.3", - "@types/node": "^16.18.50", + "@types/node": "^20.19.43", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.48.0", @@ -919,16 +919,6 @@ "node": "20 || 22 || 24" } }, - "node_modules/@loopback/build/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@loopback/context": { "version": "8.0.12", "resolved": "https://registry.npmjs.org/@loopback/context/-/context-8.0.12.tgz", @@ -1443,15 +1433,6 @@ "node": ">=14.0.0" } }, - "node_modules/@openapi-contrib/openapi-schema-to-json-schema/node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1881,10 +1862,12 @@ } }, "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "license": "MIT" + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/on-finished": { "version": "2.3.5", diff --git a/package.json b/package.json index 13b5c531..0b612181 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "2wp-api", - "version": "4.0.1", + "version": "4.1.0", "description": "PowPeg API", "keywords": [ "loopback-application", @@ -104,7 +104,7 @@ "@types/big.js": "^6.1.2", "@types/bs58": "^4.0.1", "@types/nock": "^10.0.3", - "@types/node": "^16.18.50", + "@types/node": "^20.19.43", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.48.0", @@ -121,4 +121,4 @@ "allowScripts": { "@scarf/scarf": false } -} \ No newline at end of file +} diff --git a/src/__tests__/unit/features.controller.unit.ts b/src/__tests__/unit/features.controller.unit.ts index c3904187..02562fb3 100644 --- a/src/__tests__/unit/features.controller.unit.ts +++ b/src/__tests__/unit/features.controller.unit.ts @@ -6,11 +6,14 @@ import { FeaturesController } from '../../controllers/features.controller'; import { FeaturesDataService } from '../../services'; import { FeaturesDbDataModel } from '../../models/features-data.model'; import { FeaturesMongoDbDataService } from '../../services/features-mongo.service'; +import { BackofficeFeatureFlagsService } from '../../services/backoffice-feature-flags.service'; describe('FeaturesController (unit)', () => { let mockedService: FeaturesDataService; + let mockedBackofficeService: BackofficeFeatureFlagsService; let context = stubExpressContext(); let getAll: sinon.SinonStub; + let getProviderFlags: sinon.SinonStub; beforeEach(() => { mockedService = createStubInstance(FeaturesMongoDbDataService); @@ -33,20 +36,23 @@ import { FeaturesMongoDbDataService } from '../../services/features-mongo.servic lastUpdateDate: new Date(), }, ]); + mockedBackofficeService = createStubInstance(BackofficeFeatureFlagsService); + getProviderFlags = mockedBackofficeService.getProviderFlags as sinon.SinonStub; + getProviderFlags.resolves(null); context = stubExpressContext(); }); describe('get()',() => { it('retrieves the features flags Information', async() => { - const controller = new FeaturesController(context.response, mockedService); - await controller.get(); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); let result = await context.result; expect(result.payload).not.null(); }); it('Supported Browsers are valid data', async() => { - const controller = new FeaturesController(context.response, mockedService); - await controller.get(); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); let result = await context.result; const features = >JSON.parse(result.payload); features.forEach((element: FeaturesDbDataModel) => { @@ -59,7 +65,71 @@ import { FeaturesMongoDbDataService } from '../../services/features-mongo.servic expect(element.supportedBrowsers.opera).to.be.Boolean(); }); }); + it('includes the backoffice provider availability flags in the response', async() => { + getProviderFlags.resolves({ + flags: {FLYOVER: true, UNION_BRIDGE: false, POWPEG: true}, + providers: [], + }); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); + let result = await context.result; + const features = >JSON.parse(result.payload); + const byName = new Map(features.map(feature => [feature.name, feature.value])); + expect(byName.get('flyover')).to.equal('enabled'); + expect(byName.get('union_bridge')).to.equal('disabled'); + expect(byName.get('powpeg')).to.equal('enabled'); + expect(byName.get('feature1')).to.equal('enabled'); + }); + it('serves the local features unchanged when the backoffice is unavailable', async() => { + getProviderFlags.resolves(null); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); + let result = await context.result; + const features = >JSON.parse(result.payload); + expect(features.length).to.equal(1); + expect(features[0].name).to.equal('feature1'); + }); + it('overwrites a locally stored feature with the backoffice value', async() => { + getAll.resolves([{name: 'flyover', value: 'enabled', version: 1}]); + getProviderFlags.resolves({ + flags: {FLYOVER: false, UNION_BRIDGE: false, POWPEG: false}, + providers: [], + }); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); + let result = await context.result; + const features = >JSON.parse(result.payload); + const flyover = features.filter(feature => feature.name === 'flyover'); + expect(flyover.length).to.equal(1); + expect(flyover[0].value).to.equal('disabled'); + }); + it('includes the backoffice providers with their enabled pairs', async() => { + const pairs = [ + { + fromNetwork: 'BITCOIN', + toNetwork: 'ROOTSTOCK', + fromToken: 'BTC', + toToken: 'RBTC', + enabled: true, + }, + ]; + getProviderFlags.resolves({ + flags: {}, + providers: [ + {key: 'BOLTZ', displayName: 'Boltz', enabled: true, pairs}, + {key: 'CHANGELLY', displayName: 'Changelly', enabled: false, pairs: []}, + ], + }); + const controller = new FeaturesController(context.response, mockedService, mockedBackofficeService); + await controller.get(); + let result = await context.result; + const features = >JSON.parse(result.payload); + const byName = new Map(features.map(feature => [feature.name, feature])); + expect(byName.get('boltz')?.value).to.equal('enabled'); + expect(byName.get('boltz')?.pairs).to.eql(pairs); + expect(byName.get('changelly')?.value).to.equal('disabled'); + expect(byName.get('changelly')?.pairs).to.eql([]); + }); }); }); - \ No newline at end of file diff --git a/src/__tests__/unit/services/backoffice-feature-flags.service.unit.ts b/src/__tests__/unit/services/backoffice-feature-flags.service.unit.ts new file mode 100644 index 00000000..5efd765a --- /dev/null +++ b/src/__tests__/unit/services/backoffice-feature-flags.service.unit.ts @@ -0,0 +1,493 @@ +import {expect} from '@loopback/testlab'; +import sinon from 'sinon'; +import { + BackofficeFeatureFlagsService, + applyProviderFlags, +} from '../../../services/backoffice-feature-flags.service'; +import {FeaturesDbDataModel} from '../../../models/features-data.model'; + +const ENV_KEYS = [ + 'BACKOFFICE_API_URL', + 'BACKOFFICE_API_EMAIL', + 'BACKOFFICE_API_PASSWORD', + 'DEPLOY_ENV', + 'BACKOFFICE_FLAGS_CACHE_TTL_MS', + 'BACKOFFICE_HTTP_TIMEOUT_MS', +]; + +const FLAGS_URL = 'http://backoffice.local/api/feature-flags?environment=testnet&include=providers'; + +const jsonResponse = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), {status, headers}); + +const loginResponse = () => + jsonResponse(200, {user: {}}, {'set-cookie': 'auth=session-token; HttpOnly; Path=/'}); + +const flagsResponse = (flags: Array<{key: string; value: unknown}>, providers?: unknown) => + jsonResponse(200, providers === undefined ? {flags} : {flags, providers}); + +const newService = (fetchStub: sinon.SinonStub) => + new BackofficeFeatureFlagsService(fetchStub as unknown as typeof fetch); + +describe('Service: BackofficeFeatureFlagsService', () => { + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + } + process.env.BACKOFFICE_API_URL = 'http://backoffice.local'; + process.env.BACKOFFICE_API_EMAIL = 'svc@example.com'; + process.env.BACKOFFICE_API_PASSWORD = 'a-service-password'; + process.env.DEPLOY_ENV = 'testnet'; + delete process.env.BACKOFFICE_FLAGS_CACHE_TTL_MS; + delete process.env.BACKOFFICE_HTTP_TIMEOUT_MS; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + }); + + it('resolves null when the integration is not configured', async () => { + delete process.env.BACKOFFICE_API_URL; + const fetchStub = sinon.stub(); + const service = newService(fetchStub); + expect(await service.getProviderFlags()).to.be.null(); + sinon.assert.notCalled(fetchStub); + }); + + it('logs in and retrieves the provider flags', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves( + flagsResponse([ + {key: 'FLYOVER', value: true}, + {key: 'UNION_BRIDGE', value: false}, + {key: 'POWPEG', value: true}, + {key: 'MAINTENANCE_MODE', value: false}, + ]), + ); + const service = newService(fetchStub); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags).to.eql({ + FLYOVER: true, + UNION_BRIDGE: false, + POWPEG: true, + MAINTENANCE_MODE: false, + }); + const loginCall = fetchStub.getCall(0); + expect(loginCall.args[0]).to.equal('http://backoffice.local/api/auth/login'); + const flagsCall = fetchStub.getCall(1); + expect(flagsCall.args[0]).to.equal(FLAGS_URL); + expect(flagsCall.args[1].headers.cookie).to.equal('auth=session-token'); + }); + + it('serves the cached flags within the TTL without extra requests', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(flagsResponse([{key: 'FLYOVER', value: true}])); + const service = newService(fetchStub); + await service.getProviderFlags(); + const again = await service.getProviderFlags(); + expect(again?.flags.FLYOVER).to.be.true(); + sinon.assert.callCount(fetchStub, 2); + }); + + it('re-logs in once when the session is rejected with 401', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(new Response('', {status: 401})); + fetchStub.onCall(2).resolves(loginResponse()); + fetchStub.onCall(3).resolves(flagsResponse([{key: 'POWPEG', value: true}])); + const service = newService(fetchStub); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags.POWPEG).to.be.true(); + sinon.assert.callCount(fetchStub, 4); + }); + + it('falls back to the last known flags when the backoffice becomes unreachable', async () => { + process.env.BACKOFFICE_FLAGS_CACHE_TTL_MS = '1'; + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(flagsResponse([{key: 'FLYOVER', value: true}])); + fetchStub.onCall(2).rejects(new Error('connection refused')); + const service = newService(fetchStub); + const first = await service.getProviderFlags(); + expect(first?.flags.FLYOVER).to.be.true(); + await new Promise(resolve => setTimeout(resolve, 5)); + const second = await service.getProviderFlags(); + expect(second?.flags.FLYOVER).to.be.true(); + }); + + it('serves the stale flags immediately and refreshes in the background once the TTL expires', async () => { + process.env.BACKOFFICE_FLAGS_CACHE_TTL_MS = '1'; + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(flagsResponse([{key: 'FLYOVER', value: true}])); + let resolveRefresh!: (response: Response) => void; + fetchStub.onCall(2).returns(new Promise(resolve => { + resolveRefresh = resolve; + })); + const service = newService(fetchStub); + await service.getProviderFlags(); + await new Promise(resolve => setTimeout(resolve, 5)); + const stale = await service.getProviderFlags(); + expect(stale?.flags.FLYOVER).to.be.true(); + resolveRefresh(flagsResponse([{key: 'FLYOVER', value: false}])); + await new Promise(resolve => setImmediate(resolve)); + const refreshed = await service.getProviderFlags(); + expect(refreshed?.flags.FLYOVER).to.be.false(); + }); + + it('keeps the session after a non-401 failure and skips the login on the next cycle', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(new Response('', {status: 500})); + fetchStub.onCall(2).resolves(flagsResponse([{key: 'FLYOVER', value: true}])); + const service = newService(fetchStub); + expect(await service.getProviderFlags()).to.be.null(); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags.FLYOVER).to.be.true(); + sinon.assert.callCount(fetchStub, 3); + const retryCall = fetchStub.getCall(2); + expect(retryCall.args[0]).to.equal(FLAGS_URL); + expect(retryCall.args[1].headers.cookie).to.equal('auth=session-token'); + }); + + it('keeps the session after a network failure and skips the login on the next cycle', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).rejects(new Error('connection refused')); + fetchStub.onCall(2).resolves(flagsResponse([{key: 'FLYOVER', value: true}])); + const service = newService(fetchStub); + expect(await service.getProviderFlags()).to.be.null(); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags.FLYOVER).to.be.true(); + expect(fetchStub.getCall(2).args[0]).to.equal(FLAGS_URL); + }); + + it('resolves null instead of rejecting when the first fetch fails', async () => { + const fetchStub = sinon.stub().rejects(new Error('connection refused')); + const service = newService(fetchStub); + expect(await service.getProviderFlags()).to.be.null(); + }); + + it('resolves null when the response payload is not the expected shape', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(jsonResponse(200, {unexpected: true})); + const service = newService(fetchStub); + expect(await service.getProviderFlags()).to.be.null(); + }); + + it('forwards boolean, string, number and JSON flag values', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves( + flagsResponse([ + {key: 'POWPEG', value: true}, + {key: 'TERMS_AND_CONDITIONS', value: '# TERMS OF SERVICES'}, + {key: 'MAX_AMOUNT', value: 10.5}, + {key: 'LIMITS', value: {min: '0.001', networks: ['BITCOIN']}}, + {key: 'ROLLOUT', value: ['flyover', 'powpeg']}, + ]), + ); + const service = newService(fetchStub); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags).to.eql({ + POWPEG: true, + TERMS_AND_CONDITIONS: '# TERMS OF SERVICES', + MAX_AMOUNT: 10.5, + LIMITS: {min: '0.001', networks: ['BITCOIN']}, + ROLLOUT: ['flyover', 'powpeg'], + }); + }); + + it('drops flags holding no value', async () => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves( + flagsResponse([ + {key: 'FLYOVER', value: null}, + {key: 'UNION_BRIDGE', value: undefined}, + {key: 'POWPEG', value: false}, + ]), + ); + const service = newService(fetchStub); + const retrieved = await service.getProviderFlags(); + expect(retrieved?.flags).to.eql({POWPEG: false}); + }); + + describe('providers', () => { + const pair = (attributes: Record) => ({ + id: 9, + fromNetwork: 'BITCOIN', + toNetwork: 'ROOTSTOCK', + fromToken: 'BTC', + toToken: 'RBTC', + enabled: true, + ...attributes, + }); + + const retrieveProviders = async (providers: unknown) => { + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(loginResponse()); + fetchStub.onCall(1).resolves(flagsResponse([{key: 'FLYOVER', value: true}], providers)); + return (await newService(fetchStub).getProviderFlags())?.providers; + }; + + it('keeps the enabled pairs of an enabled provider, and nothing else about it', async () => { + const providers = await retrieveProviders([ + { + id: 3, + key: 'BOLTZ', + displayName: 'Boltz', + enabled: true, + pairs: [pair({id: 9}), pair({id: 10, fromToken: 'RBTC', enabled: false})], + }, + ]); + expect(providers).to.eql([ + { + key: 'BOLTZ', + enabled: true, + pairs: [ + { + id: 9, + fromNetwork: 'BITCOIN', + toNetwork: 'ROOTSTOCK', + fromToken: 'BTC', + toToken: 'RBTC', + enabled: true, + }, + ], + }, + ]); + }); + + it('nests no pairs under a disabled provider', async () => { + const providers = await retrieveProviders([ + {id: 2, key: 'CHANGELLY', enabled: false, pairs: [pair({})]}, + ]); + expect(providers).to.eql([{key: 'CHANGELLY', enabled: false, pairs: []}]); + }); + + it('passes through attributes the backoffice adds to a pair', async () => { + const providers = await retrieveProviders([ + { + id: 5, + key: 'SYMBIOSIS', + enabled: true, + pairs: [pair({minAmount: '0.001', maxAmount: '1.5'})], + }, + ]); + expect(providers?.[0].pairs[0].minAmount).to.equal('0.001'); + expect(providers?.[0].pairs[0].maxAmount).to.equal('1.5'); + }); + + it('ignores providers without a key and a boolean enabled', async () => { + const providers = await retrieveProviders([ + {id: 1, key: 'MOCK', enabled: 'yes', pairs: []}, + {id: 2, enabled: true, pairs: []}, + {id: 3, key: 'BOLTZ', enabled: true, pairs: []}, + ]); + expect(providers).to.eql([{key: 'BOLTZ', enabled: true, pairs: []}]); + }); + + it('resolves an empty provider list when the payload carries none', async () => { + expect(await retrieveProviders(undefined)).to.eql([]); + expect(await retrieveProviders({not: 'an array'})).to.eql([]); + expect(await retrieveProviders([{id: 1, key: 'MOCK', enabled: true}])).to.eql([ + {key: 'MOCK', enabled: true, pairs: []}, + ]); + }); + }); + + describe('applyProviderFlags()', () => { + it('appends missing provider features and overwrites existing ones', () => { + const existing = new FeaturesDbDataModel(); + existing.name = 'flyover'; + existing.value = 'enabled'; + existing.version = 1; + const merged = applyProviderFlags([existing], { + flags: { + FLYOVER: false, + UNION_BRIDGE: true, + POWPEG: false, + NEW_PROVIDER: true, + }, + providers: [], + }); + const byName = new Map(merged.map(feature => [feature.name, feature.value])); + expect(merged.length).to.equal(4); + expect(byName.get('flyover')).to.equal('disabled'); + expect(byName.get('union_bridge')).to.equal('enabled'); + expect(byName.get('powpeg')).to.equal('disabled'); + expect(byName.get('new_provider')).to.equal('enabled'); + }); + + it('does not let a boolean flag overwrite a feature holding text', () => { + const terms = new FeaturesDbDataModel(); + terms.name = 'terms_and_conditions'; + terms.value = '# TERMS OF SERVICES'; + const merged = applyProviderFlags([terms], { + flags: {TERMS_AND_CONDITIONS: true}, + providers: [], + }); + expect(merged.length).to.equal(1); + expect(merged[0].value).to.equal('# TERMS OF SERVICES'); + }); + + it('serves string, number and JSON flag values as they stand', () => { + const limits = {min: '0.001', networks: ['BITCOIN']}; + const merged = applyProviderFlags([], { + flags: { + TERMS_AND_CONDITIONS: '# BACKOFFICE TERMS', + MAX_AMOUNT: 10.5, + LIMITS: limits, + FLYOVER: true, + }, + providers: [], + }); + const byName = new Map(merged.map(feature => [feature.name, feature.value])); + expect(byName.get('terms_and_conditions')).to.equal('# BACKOFFICE TERMS'); + expect(byName.get('max_amount')).to.equal(10.5); + expect(byName.get('limits')).to.eql(limits); + expect(byName.get('flyover')).to.equal('enabled'); + }); + + it('lets a non-boolean flag replace the stored value', () => { + const terms = new FeaturesDbDataModel(); + terms.name = 'terms_and_conditions'; + terms.value = '# STORED TERMS'; + const merged = applyProviderFlags([terms], { + flags: {TERMS_AND_CONDITIONS: '# BACKOFFICE TERMS'}, + providers: [], + }); + expect(merged.length).to.equal(1); + expect(merged[0].value).to.equal('# BACKOFFICE TERMS'); + }); + + it('appends a feature per provider with its pairs nested', () => { + const pairs = [ + { + fromNetwork: 'BITCOIN', + toNetwork: 'ROOTSTOCK', + fromToken: 'BTC', + toToken: 'RBTC', + enabled: true, + }, + ]; + const merged = applyProviderFlags([], { + flags: {}, + providers: [ + {key: 'BOLTZ', enabled: true, pairs}, + {key: 'CHANGELLY', enabled: false, pairs: []}, + ], + }); + const byName = new Map(merged.map(feature => [feature.name, feature])); + expect(merged.length).to.equal(2); + expect(byName.get('boltz')?.value).to.equal('enabled'); + expect(byName.get('boltz')).to.have.property('pairs', pairs); + expect(byName.get('changelly')?.value).to.equal('disabled'); + expect(byName.get('changelly')).to.have.property('pairs', []); + }); + + it('nests the pairs on a locally stored feature carrying the provider name', () => { + const local = new FeaturesDbDataModel(); + local.name = 'boltz'; + local.value = 'disabled'; + const merged = applyProviderFlags([local], { + flags: {}, + providers: [{key: 'BOLTZ', enabled: true, pairs: []}], + }); + expect(merged.length).to.equal(1); + expect(merged[0].value).to.equal('enabled'); + expect(merged[0]).to.have.property('pairs', []); + }); + + describe('property flags', () => { + const browsers = { + chrome: true, firefox: false, safari: false, edge: true, brave: false, chromium: true, opera: false, + }; + + const walletFeature = () => { + const wallet = new FeaturesDbDataModel(); + wallet.name = 'wallet_ledger'; + wallet.value = 'enabled'; + wallet.supportedBrowsers = { + chrome: true, firefox: true, safari: true, edge: true, brave: true, chromium: true, opera: true, + }; + return wallet; + }; + + it('sets a property flag on the feature of its boolean flag', () => { + const merged = applyProviderFlags([walletFeature()], { + flags: { + WALLET_LEDGER: true, + WALLET_LEDGER_SUPPORTED_BROWSERS: browsers, + FLYOVER: true, + }, + providers: [], + }); + const byName = new Map(merged.map(feature => [feature.name, feature])); + expect(merged.length).to.equal(2); + expect(byName.get('wallet_ledger')?.supportedBrowsers).to.eql(browsers); + expect(byName.get('flyover')?.supportedBrowsers).to.be.undefined(); + }); + + it('appends a missing feature carrying its property flags', () => { + const merged = applyProviderFlags([], { + flags: {WALLET_LEDGER: false, WALLET_LEDGER_SUPPORTED_BROWSERS: browsers}, + providers: [], + }); + expect(merged.length).to.equal(1); + expect(merged[0].name).to.equal('wallet_ledger'); + expect(merged[0].value).to.equal('disabled'); + expect(merged[0].supportedBrowsers).to.eql(browsers); + }); + + it('keeps the existing supportedBrowsers when the flag has none', () => { + const merged = applyProviderFlags([walletFeature()], { + flags: {WALLET_LEDGER: false}, + providers: [], + }); + expect(merged[0].supportedBrowsers).to.eql(walletFeature().supportedBrowsers); + expect(merged[0].value).to.equal('disabled'); + }); + + it('pairs a property flag with the longest matching boolean flag', () => { + const merged = applyProviderFlags([], { + flags: {WALLET: true, WALLET_LEDGER: true, WALLET_LEDGER_SUPPORTED_BROWSERS: browsers}, + providers: [], + }); + const byName = new Map(merged.map(feature => [feature.name, feature])); + expect(byName.get('wallet_ledger')?.supportedBrowsers).to.eql(browsers); + expect(byName.get('wallet')?.supportedBrowsers).to.be.undefined(); + }); + + it('serves a property flag matching no boolean flag as a flag of its own', () => { + const merged = applyProviderFlags([], { + flags: {ORPHAN_SUPPORTED_BROWSERS: browsers}, + providers: [], + }); + expect(merged.length).to.equal(1); + expect(merged[0].name).to.equal('orphan_supported_browsers'); + expect(merged[0].value).to.eql(browsers); + }); + + it('never lets a property flag shadow a reserved attribute', () => { + const merged = applyProviderFlags([], { + flags: {WALLET_LEDGER: true, WALLET_LEDGER_VALUE: 'tampered'}, + providers: [], + }); + const byName = new Map(merged.map(feature => [feature.name, feature.value])); + expect(byName.get('wallet_ledger')).to.equal('enabled'); + expect(byName.get('wallet_ledger_value')).to.equal('tampered'); + }); + }); + }); +}); diff --git a/src/controllers/features.controller.ts b/src/controllers/features.controller.ts index c4418d16..dc752173 100644 --- a/src/controllers/features.controller.ts +++ b/src/controllers/features.controller.ts @@ -3,6 +3,7 @@ import { RestBindings, get, getModelSchemaRef, Response, } from '@loopback/rest' import { getLogger, Logger } from '../utils/logger'; import { ServicesBindings } from '../dependency-injection-bindings'; import { FeaturesDataService } from '../services/features-data.service'; +import { BackofficeFeatureFlagsService, MergedFeature, applyProviderFlags } from '../services/backoffice-feature-flags.service'; import { FeaturesDbDataModel } from '../models/features-data.model'; export class FeaturesController { @@ -14,6 +15,8 @@ export class FeaturesController { @inject(RestBindings.Http.RESPONSE) private response: Response, @inject(ServicesBindings.FEATURES_SERVICE) featuresDatService: FeaturesDataService, + @inject(ServicesBindings.BACKOFFICE_FEATURE_FLAGS_SERVICE) + private backofficeFeatureFlagsService: BackofficeFeatureFlagsService, ) { this.featuresDatService = featuresDatService; this.logger = getLogger('features-controller'); @@ -60,11 +63,15 @@ export class FeaturesController { */ public async get(): Promise { this.logger.debug({method: 'get'}, 'started'); - let features = [new FeaturesDbDataModel()]; + let features: MergedFeature[] = [new FeaturesDbDataModel()]; let responseCode = this.HTTP_ERROR; try { features = await this.featuresDatService.getAll(); responseCode = this.HTTP_SUCCESS_OK; + const backofficeFlags = await this.backofficeFeatureFlagsService.getProviderFlags(); + if (backofficeFlags) { + features = applyProviderFlags(features, backofficeFlags); + } this.logger.info({method: 'get', featureCount: features.length}, 'Retrieved the features'); } catch (err) { this.logger.warn({method: 'get', err}, 'Failed to retrieve features'); diff --git a/src/dependency-injection-bindings.ts b/src/dependency-injection-bindings.ts index 33cb0cd9..aeedc0d6 100644 --- a/src/dependency-injection-bindings.ts +++ b/src/dependency-injection-bindings.ts @@ -37,4 +37,5 @@ export const ServicesBindings = { UTXO_PROVIDER_SERVICE: 'services.UtxoProvider', FEATURES_SERVICE: 'services.FeaturesDataService', FLYOVER_SERVICE: 'services.FlyoverService', + BACKOFFICE_FEATURE_FLAGS_SERVICE: 'services.BackofficeFeatureFlagsService', }; diff --git a/src/dependency-injection-handler.ts b/src/dependency-injection-handler.ts index e25e71d9..7ddb42c5 100644 --- a/src/dependency-injection-handler.ts +++ b/src/dependency-injection-handler.ts @@ -20,6 +20,7 @@ import {RskNodeService} from './services/rsk-node.service'; import {SyncStatusMongoService} from './services/sync-status-mongo.service'; import { PegoutDataProcessor } from './services/pegout-data.processor'; import { FeaturesMongoDbDataService } from './services/features-mongo.service'; +import { BackofficeFeatureFlagsService } from './services/backoffice-feature-flags.service'; export class DependencyInjectionHandler { public static configureDependencies(app: Application): void { @@ -154,5 +155,9 @@ export class DependencyInjectionHandler { .toClass(FeaturesMongoDbDataService) .inScope(BindingScope.SINGLETON); + app + .bind(ServicesBindings.BACKOFFICE_FEATURE_FLAGS_SERVICE) + .toClass(BackofficeFeatureFlagsService) + .inScope(BindingScope.SINGLETON); } } diff --git a/src/services/backoffice-feature-flags.service.ts b/src/services/backoffice-feature-flags.service.ts new file mode 100644 index 00000000..fbdf222a --- /dev/null +++ b/src/services/backoffice-feature-flags.service.ts @@ -0,0 +1,273 @@ +import { getLogger, Logger } from '../utils/logger'; +import { FeaturesDbDataModel } from '../models/features-data.model'; + +/** Any JSON value the backoffice can hold for a flag, except null. */ +export type FlagValue = boolean | string | number | Record | unknown[]; + +export type ProviderFlags = Record; + +/** + * A feature as `/features` serves it: the stored shape, widened to the flag + * values the backoffice can hold, plus the pairs of a provider. + */ +export type MergedFeature = Omit & { + value: FlagValue; + pairs?: ProviderPair[]; +}; + +/** + * A pair is owned by the backoffice: nothing but `enabled` is read here, so it + * is served as it arrives and new backoffice attributes need no code change. + */ +export interface ProviderPair { + enabled: boolean; + [attribute: string]: unknown; +} + +export interface ProviderStatus { + key: string; + enabled: boolean; + /** Pairs the provider can actually serve: its enabled pairs while it is enabled. */ + pairs: ProviderPair[]; +} + +export interface BackofficeFeatureFlags { + flags: ProviderFlags; + providers: ProviderStatus[]; +} + + +export class BackofficeFeatureFlagsService { + logger: Logger = getLogger('backoffice-feature-flags-service'); + private readonly baseUrl = (process.env.BACKOFFICE_API_URL ?? '').trim().replace(/\/+$/, ''); + private readonly email = process.env.BACKOFFICE_API_EMAIL ?? ''; + private readonly password = process.env.BACKOFFICE_API_PASSWORD ?? ''; + private readonly environment = process.env.DEPLOY_ENV ?? 'local'; + private readonly cacheTtlMs = Number(process.env.BACKOFFICE_FLAGS_CACHE_TTL_MS) || 60000; + private readonly timeoutMs = Number(process.env.BACKOFFICE_HTTP_TIMEOUT_MS) || 2000; + private sessionCookie: string | null = null; + private cache: { data: BackofficeFeatureFlags; fetchedAt: number } | null = null; + private inFlight: Promise | null = null; + + constructor(private readonly fetchFn: typeof fetch = fetch) { } + + public async getProviderFlags(): Promise { + if (!this.baseUrl || !this.email || !this.password) return null; + if (!this.cache || Date.now() - this.cache.fetchedAt >= this.cacheTtlMs) { + this.inFlight ??= this.refresh().finally(() => { + this.inFlight = null; + }); + if (!this.cache) return this.inFlight; + } + return this.cache?.data ?? null; + } + + private async refresh(): Promise { + try { + let response = await this.requestFlags(); + if (response.status === 401) { + this.sessionCookie = null; + response = await this.requestFlags(); + } + if (!response.ok) { + throw new Error(`Backoffice /api/feature-flags responded with status ${response.status}`); + } + this.cache = { data: this.parseFlags(await response.json()), fetchedAt: Date.now() }; + return this.cache.data; + } catch (err) { + this.logger.warn( + { method: 'refresh', err }, + 'Failed to retrieve feature flags from backoffice; serving last known values', + ); + return this.cache?.data ?? null; + } + } + + private request(path: string, init: RequestInit = {}): Promise { + return this.fetchFn(`${this.baseUrl}${path}`, { + ...init, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } + + private async requestFlags(): Promise { + const params = new URLSearchParams({ + environment: this.environment, + include: 'providers', + }); + if (!this.sessionCookie) await this.login(); + return this.request(`/api/feature-flags?${params.toString()}`, { + headers: { accept: 'application/json', cookie: this.sessionCookie ?? '' }, + }); + } + + private async login(): Promise { + const response = await this.request('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ email: this.email, password: this.password }), + }); + if (!response.ok) { + throw new Error(`Backoffice login failed with status ${response.status}`); + } + const cookies = response.headers.getSetCookie().map(cookie => cookie.split(';')[0]); + if (cookies.length === 0) { + throw new Error('Backoffice login response did not include a session cookie'); + } + this.sessionCookie = cookies.join('; '); + } + + private parseFlags(payload: unknown): BackofficeFeatureFlags { + const body = payload as { flags?: { key: unknown; value: unknown }[]; providers?: unknown }; + const rows = body?.flags; + if (!Array.isArray(rows)) { + throw new Error('Invalid backoffice /api/feature-flags response: missing "flags" array'); + } + const flags: ProviderFlags = {}; + const dropped: string[] = []; + rows.forEach(row => { + if (typeof row?.key !== 'string') return; + if (isFlagValue(row.value)) { + flags[row.key] = row.value; + } else { + dropped.push(row.key); + } + }); + if (dropped.length > 0) { + this.logger.warn( + { method: 'parseFlags', dropped }, + 'Ignoring backoffice flags holding no value', + ); + } + return { flags, providers: this.parseProviders(body.providers) }; + } + + /** + * Keeps every provider carrying a `key` and a boolean `enabled`, along with + * the pairs it can serve. A payload without providers is served as flags only. + */ + private parseProviders(payload: unknown): ProviderStatus[] { + if (!Array.isArray(payload)) return []; + const rows = payload.filter( + row => typeof row?.key === 'string' && typeof row.enabled === 'boolean', + ); + if (rows.length < payload.length) { + this.logger.warn( + { method: 'parseProviders', dropped: payload.length - rows.length }, + 'Ignoring backoffice providers without a key and a boolean enabled', + ); + } + return rows.map(row => ({ + key: row.key, + enabled: row.enabled, + pairs: row.enabled ? parsePairs(row.pairs) : [], + })); + } + +} + +/** Every JSON value carries a flag, except null and a missing one. */ +function isFlagValue(value: unknown): value is FlagValue { + return value !== null && ['boolean', 'string', 'number', 'object'].includes(typeof value); +} + +/** Serves the pairs the backoffice marks as enabled, as they arrive. */ +function parsePairs(payload: unknown): ProviderPair[] { + return Array.isArray(payload) ? payload.filter(pair => pair?.enabled === true) : []; +} + +export function applyProviderFlags( + features: MergedFeature[], + backofficeFlags: BackofficeFeatureFlags, +): MergedFeature[] { + const merged: MergedFeature[] = [...features]; + const { flags, properties } = splitPropertyFlags(backofficeFlags.flags); + Object.entries(flags).forEach(([key, value]) => { + upsertFeature(merged, key, value, undefined, properties[key]); + }); + backofficeFlags.providers.forEach(provider => { + upsertFeature(merged, provider.key, provider.enabled, provider.pairs); + }); + return merged; +} + +/** Core feature fields that can never be set via property flags. */ +const RESERVED_PROPERTIES = ['name', 'value', 'pairs']; + +/** + * A non-boolean flag whose key extends a boolean flag's key sets a property + * on that feature, named after the camelCased remainder (e.g. + * `WALLET_LEDGER_SUPPORTED_BROWSERS` sets `supportedBrowsers` of + * `wallet_ledger`). The longest matching boolean flag wins. A flag whose + * remainder camelCases to a reserved name, or that matches no boolean flag, + * stays a flag of its own. + */ +function splitPropertyFlags(all: ProviderFlags): { + flags: ProviderFlags; + properties: Record>; +} { + const booleanKeys = Object.keys(all) + .filter(key => typeof all[key] === 'boolean') + .sort((a, b) => b.length - a.length); + const flags: ProviderFlags = {}; + const properties: Record> = {}; + Object.entries(all).forEach(([key, value]) => { + const base = typeof value === 'boolean' + ? undefined + : booleanKeys.find( + candidate => key.startsWith(`${candidate}_`) && key.length > candidate.length + 1, + ); + const property = base ? toPropertyName(key.slice(base.length + 1)) : null; + if (base && property && !RESERVED_PROPERTIES.includes(property)) { + (properties[base] ??= {})[property] = value; + } else { + flags[key] = value; + } + }); + return { flags, properties }; +} + +function toPropertyName(suffix: string): string { + const parts = suffix.toLowerCase().split('_'); + return ( + parts[0] + + parts + .slice(1) + .map(part => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join('') + ); +} + +/** + * Writes `key` into the feature list, appending it when missing. A boolean + * reads as `enabled`/`disabled`, every other value is served as it stands. + * A boolean never overwrites a feature holding neither (e.g. the stored + * `terms_and_conditions` text), which the backoffice can still replace by + * serving a value of its own; a skipped feature takes no property flags + * either. + */ +function upsertFeature( + merged: MergedFeature[], + key: string, + flagValue: FlagValue, + pairs?: ProviderPair[], + attributes?: Record, +): void { + const name = key.toLowerCase(); + const isBoolean = typeof flagValue === 'boolean'; + let value = flagValue; + if (isBoolean) { + value = flagValue ? 'enabled' : 'disabled'; + } + const existing = merged.find(feature => feature.name === name); + if (!existing) { + merged.push(Object.assign(new FeaturesDbDataModel(), { name, value, pairs }, attributes)); + return; + } + if (isBoolean && existing.value !== 'enabled' && existing.value !== 'disabled') { + return; + } + existing.value = value; + existing.pairs = pairs; + Object.assign(existing, attributes); +} diff --git a/src/services/daemon.service.ts b/src/services/daemon.service.ts index 1bdb0b60..66e5049b 100644 --- a/src/services/daemon.service.ts +++ b/src/services/daemon.service.ts @@ -17,7 +17,7 @@ export class DaemonService implements IDaemonService { pegoutDataProcessor: PegoutDataProcessor; rskBlockProcessorPublisher: RskBlockProcessorPublisher - dataFetchInterval: NodeJS.Timer; + dataFetchInterval: NodeJS.Timeout; started: boolean; logger: Logger; diff --git a/src/services/index.ts b/src/services/index.ts index ba752a15..3902eb90 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -14,3 +14,4 @@ export * from './sync-status-data.service'; export * from './rsk-node.service'; export * from './features-data.service'; export * from './flyover.service'; +export * from './backoffice-feature-flags.service';