Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions ENV_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
35 changes: 9 additions & 26 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "2wp-api",
"version": "4.0.1",
"version": "4.1.0",
"description": "PowPeg API",
"keywords": [
"loopback-application",
Expand Down Expand Up @@ -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",
Expand All @@ -121,4 +121,4 @@
"allowScripts": {
"@scarf/scarf": false
}
}
}
80 changes: 75 additions & 5 deletions src/__tests__/unit/features.controller.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 = <Array<FeaturesDbDataModel>>JSON.parse(result.payload);
features.forEach((element: FeaturesDbDataModel) => {
Expand All @@ -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 = <Array<FeaturesDbDataModel>>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 = <Array<FeaturesDbDataModel>>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 = <Array<FeaturesDbDataModel>>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 = <Array<FeaturesDbDataModel & {pairs?: unknown}>>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([]);
});
});

});

Loading
Loading