From a758af4718ef76edd7280332341e2186171ed60d Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sat, 22 Aug 2026 19:07:41 +0200
Subject: [PATCH 01/15] 01a028e6 - Add front-api application and image builds
(#1)
* feat: add front-api HTTP layer and image workflows.
This layer answers /version, a filtered swagger snapshot, short-TTL
GET cache, optional Postgres reads, and optional in-memory quotes.
The swagger snapshot is an allowlist of those served routes only.
* fix: tighten test permissions and startup log.
Test workflow declares contents: read. Startup no longer prints the
backend URL.
* fix: remember swap quotes by source and target name.
Matches the buy/sell variant set so a name/name swap body hits RAM.
---
.dockerignore | 3 +
.github/workflows/auto-release-pr.yaml | 72 +++
.github/workflows/front-api-dev.yaml | 56 +++
.github/workflows/front-api-prd.yaml | 56 +++
.github/workflows/test.yml | 19 +
.gitignore | 3 +
Dockerfile | 7 +
package.json | 9 +
server.js | 648 +++++++++++++++++++++++++
test/test-server.sh | 178 +++++++
10 files changed, 1051 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/auto-release-pr.yaml
create mode 100644 .github/workflows/front-api-dev.yaml
create mode 100644 .github/workflows/front-api-prd.yaml
create mode 100644 .github/workflows/test.yml
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 package.json
create mode 100644 server.js
create mode 100755 test/test-server.sh
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..281e557
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+.git
+.env
diff --git a/.github/workflows/auto-release-pr.yaml b/.github/workflows/auto-release-pr.yaml
new file mode 100644
index 0000000..31e8b00
--- /dev/null
+++ b/.github/workflows/auto-release-pr.yaml
@@ -0,0 +1,72 @@
+name: Auto Release PR
+
+on:
+ push:
+ branches: [develop]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: auto-release-pr
+ cancel-in-progress: false
+
+jobs:
+ create-release-pr:
+ name: Create Release PR
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Fetch main branch
+ run: git fetch origin main
+
+ - name: Check for existing PR
+ id: check-pr
+ run: |
+ PR_COUNT=$(gh pr list --base main --head develop --state open --json number --jq 'length')
+ echo "pr_exists=$([[ $PR_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check for differences
+ id: check-diff
+ if: steps.check-pr.outputs.pr_exists == 'false'
+ run: |
+ DIFF_COUNT=$(git rev-list --count origin/main..origin/develop)
+ echo "has_changes=$([[ $DIFF_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
+ echo "commit_count=$DIFF_COUNT" >> $GITHUB_OUTPUT
+
+ - name: Create Release PR
+ if: steps.check-pr.outputs.pr_exists == 'false' && steps.check-diff.outputs.has_changes == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COMMIT_COUNT: ${{ steps.check-diff.outputs.commit_count }}
+ run: |
+ printf '%s\n' \
+ "## Automatic Release PR" \
+ "" \
+ "This PR was automatically created after changes were pushed to develop." \
+ "It is the only path from develop onto main for this repository." \
+ "Merging it publishes the production image tag and is a human decision." \
+ "Do not merge until develop has been checked on the development hub." \
+ "" \
+ "**Commits:** ${COMMIT_COUNT} new commit(s)" \
+ "" \
+ "### Checklist" \
+ "- [ ] Review all changes" \
+ "- [ ] Verify CI passes" \
+ "- [ ] Approve and merge when ready for production" \
+ > /tmp/pr-body.md
+
+ gh pr create \
+ --draft \
+ --base main \
+ --head develop \
+ --title "Release: develop -> main" \
+ --body-file /tmp/pr-body.md
diff --git a/.github/workflows/front-api-dev.yaml b/.github/workflows/front-api-dev.yaml
new file mode 100644
index 0000000..9273915
--- /dev/null
+++ b/.github/workflows/front-api-dev.yaml
@@ -0,0 +1,56 @@
+name: front-api DEV
+
+on:
+ push:
+ branches: [develop]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ IMAGE: dfxswiss/front-api
+ TAG: beta
+
+jobs:
+ build-and-push:
+ name: Build and push Docker image (DEV)
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ env.IMAGE }}:${{ env.TAG }}
+ ${{ env.IMAGE }}:${{ github.sha }}
+ platforms: linux/arm64
+
+ - name: Notify infrastructure to pull :beta
+ env:
+ GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
+ run: |
+ set -euo pipefail
+ repo="${{ secrets.DISPATCH_REPO }}"
+ if [ -z "${GH_TOKEN}" ] || [ -z "${repo}" ]; then
+ echo "::warning::DISPATCH_TOKEN or DISPATCH_REPO unset — set them like the other product image repos"
+ exit 0
+ fi
+ gh api "repos/${repo}/dispatches" \
+ -f event_type=image-published \
+ -f "client_payload[image]=${IMAGE}" \
+ -f "client_payload[tag]=${TAG}" \
+ -f "client_payload[sha]=${GITHUB_SHA}"
diff --git a/.github/workflows/front-api-prd.yaml b/.github/workflows/front-api-prd.yaml
new file mode 100644
index 0000000..4cb659c
--- /dev/null
+++ b/.github/workflows/front-api-prd.yaml
@@ -0,0 +1,56 @@
+name: front-api PRD
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ IMAGE: dfxswiss/front-api
+ TAG: latest
+
+jobs:
+ build-and-push:
+ name: Build and push Docker image (PRD)
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ env.IMAGE }}:${{ env.TAG }}
+ ${{ env.IMAGE }}:${{ github.sha }}
+ platforms: linux/arm64
+
+ - name: Notify infrastructure to pull :latest
+ env:
+ GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
+ run: |
+ set -euo pipefail
+ repo="${{ secrets.DISPATCH_REPO }}"
+ if [ -z "${GH_TOKEN}" ] || [ -z "${repo}" ]; then
+ echo "::warning::DISPATCH_TOKEN or DISPATCH_REPO unset — set them like the other product image repos"
+ exit 0
+ fi
+ gh api "repos/${repo}/dispatches" \
+ -f event_type=image-published \
+ -f "client_payload[image]=${IMAGE}" \
+ -f "client_payload[tag]=${TAG}" \
+ -f "client_payload[sha]=${GITHUB_SHA}"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..3a1ff00
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,19 @@
+name: test
+
+on:
+ pull_request:
+ push:
+ branches: [develop, main]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - run: bash test/test-server.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5a00c13
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.env
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f19475b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:22-alpine
+WORKDIR /app
+COPY package.json server.js ./
+RUN npm install --omit=dev
+ENV PORT=3000
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8a9e7e2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "dfx-front-api",
+ "private": true,
+ "version": "0.1.0",
+ "main": "server.js",
+ "dependencies": {
+ "pg": "^8.16.3"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..879b008
--- /dev/null
+++ b/server.js
@@ -0,0 +1,648 @@
+'use strict';
+
+const http = require('http');
+const net = require('net');
+const { URL } = require('url');
+
+if (!process.env.BACKEND_URL) {
+ console.error('BACKEND_URL required');
+ process.exit(1);
+}
+const PORT = +(process.env.PORT || 3000);
+const BIND = process.env.BIND || '0.0.0.0';
+const BACKEND = process.env.BACKEND_URL;
+const TTL_MS = +(process.env.CACHE_TTL_MS || 15000);
+const QUOTE_TTL_MS = 300000;
+const CACHE_MAX = +(process.env.CACHE_MAX || 500);
+const STARTED = new Date().toISOString();
+
+// Public GET prefixes this layer may answer from cache. Authenticated
+// requests are never cached — they always go to the backend.
+const CACHE_PREFIXES = [
+ '/v1/asset',
+ '/v1/fiat',
+ '/v1/country',
+ '/v1/language',
+ '/v1/statistic',
+ '/v1/coin',
+ '/v1/setting',
+ '/v1/bank',
+ '/v1/app',
+];
+
+const cache = new Map();
+const quoteBook = { buy: new Map(), sell: new Map(), swap: new Map(), realunit: new Map(), filledAt: 0 };
+let swaggerSpec = null;
+let pool = null;
+
+try {
+ if (process.env.SQL_HOST) {
+ if (!process.env.SQL_PORT || !process.env.SQL_DB || !process.env.SQL_USERNAME || process.env.SQL_PASSWORD === undefined) {
+ console.error('SQL_HOST set but SQL_PORT/SQL_DB/SQL_USERNAME/SQL_PASSWORD missing');
+ process.exit(1);
+ }
+ const { Pool } = require('pg');
+ const sslOn = String(process.env.SQL_SSL || '') === 'true';
+ pool = new Pool({
+ host: process.env.SQL_HOST,
+ port: +process.env.SQL_PORT,
+ user: process.env.SQL_USERNAME,
+ password: process.env.SQL_PASSWORD,
+ database: process.env.SQL_DB,
+ ssl: sslOn ? { rejectUnauthorized: false } : false,
+ max: 4,
+ idleTimeoutMillis: 30000,
+ });
+ pool.on('error', (err) => console.error('pg pool', err.message));
+ }
+} catch (err) {
+ console.error('pg init failed:', err.message);
+ process.exit(1);
+}
+
+function cacheKey(req) {
+ return req.method + ' ' + req.url;
+}
+
+function isCacheable(req) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
+ if (req.headers.authorization) return false;
+ const path = (req.url || '/').split('?')[0];
+ if (path === '/' || path === '/version' || path === '/swagger' || path === '/swagger-json') return true;
+ return CACHE_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
+}
+
+function getCached(key) {
+ return cache.get(key) || null;
+}
+
+function putCache(key, status, headers, body) {
+ if (cache.size >= CACHE_MAX) {
+ const oldest = cache.keys().next().value;
+ if (oldest !== undefined) cache.delete(oldest);
+ }
+ cache.set(key, { status, headers, body, exp: Date.now() + TTL_MS });
+}
+
+function localVersion() {
+ return { commit: 'front-api', startedAt: STARTED };
+}
+
+function attachRequestTimeout(req, ms, onTimeout) {
+ req.setTimeout(ms, onTimeout);
+}
+
+function httpJson(method, urlPath, body) {
+ return new Promise((resolve, reject) => {
+ const target = new URL(BACKEND);
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
+ const req = http.request(
+ {
+ hostname: target.hostname,
+ port: target.port || 80,
+ path: urlPath,
+ method,
+ headers: payload
+ ? { 'content-type': 'application/json', 'content-length': payload.length }
+ : {},
+ },
+ (resp) => {
+ const chunks = [];
+ resp.on('data', (c) => chunks.push(c));
+ resp.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ try {
+ resolve({ status: resp.statusCode, json: JSON.parse(raw) });
+ } catch (err) {
+ reject(err);
+ }
+ });
+ },
+ );
+ req.on('error', reject);
+ attachRequestTimeout(req, 20000, () => {
+ req.destroy();
+ reject(new Error('timeout'));
+ });
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+function pairKey(kind, body) {
+ const cur = (body && body.currency && (body.currency.id || body.currency.name)) || '';
+ const src =
+ (body && body.sourceAsset && (body.sourceAsset.id || body.sourceAsset.name)) ||
+ (body && body.asset && (body.asset.id || body.asset.uniqueName || body.asset.name)) ||
+ '';
+ const target = (body && body.targetAsset && (body.targetAsset.id || body.targetAsset.name)) || '';
+ const pm = (body && body.paymentMethod) || 'Bank';
+ return [kind, cur, src, target, pm].join('|');
+}
+
+function rememberQuote(map, kind, rec, variants) {
+ for (const body of variants) map.set(pairKey(kind, body), rec);
+}
+
+const RAM_GET_PATHS = [
+ '/v1/realunit/quote/buyPrice',
+ '/v1/realunit/quote/buyShares',
+ '/v1/realunit/quote/info',
+ '/v1/realunit/quote/price',
+ '/v1/realunit/brokerbot/buyPrice',
+ '/v1/realunit/brokerbot/buyShares',
+ '/v1/realunit/brokerbot/info',
+ '/v1/realunit/brokerbot/price',
+];
+
+function isServedPath(path) {
+ const p = (path || '/').split('?')[0];
+ if (
+ p === '/' ||
+ p === '/version' ||
+ p === '/swagger' ||
+ p === '/swagger/' ||
+ p === '/swagger-json' ||
+ p === '/swagger-json/' ||
+ p === '/swagger-ui' ||
+ p === '/swagger-ui/'
+ ) {
+ return true;
+ }
+ if (p === '/v1/buy/quote' || p === '/v1/sell/quote' || p === '/v1/swap/quote') return true;
+ if (RAM_GET_PATHS.includes(p)) return true;
+ return CACHE_PREFIXES.some((pref) => p === pref || p.startsWith(pref + '/'));
+}
+
+function scaleQuote(stored, body) {
+ const out = JSON.parse(JSON.stringify(stored.json));
+ const rate = Number(out.rate);
+ const wantsScale = body.amount != null || body.targetAmount != null;
+ if (wantsScale && !(rate > 0)) return null;
+ if (body.amount != null) {
+ out.amount = body.amount;
+ out.estimatedAmount = body.amount / rate;
+ if (out.fees && typeof out.fees.rate === 'number') {
+ out.feeAmount = body.amount * (out.fees.rate || 0) + (out.fees.fixed || 0);
+ }
+ } else if (body.targetAmount != null) {
+ out.estimatedAmount = body.targetAmount;
+ out.amount = body.targetAmount * rate;
+ if (out.fees && typeof out.fees.rate === 'number') {
+ out.feeAmount = out.amount * (out.fees.rate || 0) + (out.fees.fixed || 0);
+ }
+ }
+ return out;
+}
+
+function isQuoteFresh(stored) {
+ return !!(stored && stored.json && typeof stored.at === 'number' && Date.now() - stored.at <= QUOTE_TTL_MS);
+}
+
+async function refreshQuoteBook() {
+ try {
+ const assets = (await httpJson('GET', '/v1/asset')).json;
+ const fiats = (await httpJson('GET', '/v1/fiat')).json;
+ if (!Array.isArray(assets) || !Array.isArray(fiats)) return;
+ const named = ['CHF', 'EUR', 'USD']
+ .map((n) => fiats.find((f) => f.name === n))
+ .filter((f) => f && f.id);
+ if (!named.find((f) => f.name === 'CHF')) {
+ console.error('quote book refresh: no CHF, book unchanged');
+ return;
+ }
+ const buy = new Map();
+ const sell = new Map();
+ const swap = new Map();
+ const realunit = new Map();
+ const buyable = assets.filter((a) => a.buyable).slice(0, 12);
+ const sellable = assets.filter((a) => a.sellable).slice(0, 8);
+ for (const fiat of named) {
+ const methods = fiat.name === 'CHF' ? ['Bank', 'Instant'] : ['Bank'];
+ for (const pm of methods) {
+ for (const asset of buyable) {
+ const body = { currency: { id: fiat.id }, asset: { id: asset.id }, amount: 100, paymentMethod: pm };
+ try {
+ const got = await httpJson('PUT', '/v1/buy/quote', body);
+ if (got.status === 200 && got.json) {
+ const rec = { json: got.json, at: Date.now() };
+ rememberQuote(buy, 'buy', rec, [
+ body,
+ { currency: { name: fiat.name }, asset: { id: asset.id }, paymentMethod: pm },
+ { currency: { id: fiat.id }, asset: { name: asset.name }, paymentMethod: pm },
+ { currency: { name: fiat.name }, asset: { name: asset.name }, paymentMethod: pm },
+ ...(asset.uniqueName
+ ? [
+ { currency: { id: fiat.id }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
+ { currency: { name: fiat.name }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
+ ]
+ : []),
+ ]);
+ }
+ } catch (err) {
+ console.error('quote refresh buy', fiat.name, asset.id, pm, err.message);
+ }
+ }
+ for (const asset of sellable) {
+ const body = { currency: { id: fiat.id }, asset: { id: asset.id }, amount: 0.01, paymentMethod: pm };
+ try {
+ const got = await httpJson('PUT', '/v1/sell/quote', body);
+ if (got.status === 200 && got.json) {
+ const rec = { json: got.json, at: Date.now() };
+ rememberQuote(sell, 'sell', rec, [
+ body,
+ { currency: { name: fiat.name }, asset: { id: asset.id }, paymentMethod: pm },
+ { currency: { id: fiat.id }, asset: { name: asset.name }, paymentMethod: pm },
+ { currency: { name: fiat.name }, asset: { name: asset.name }, paymentMethod: pm },
+ ...(asset.uniqueName
+ ? [
+ { currency: { id: fiat.id }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
+ { currency: { name: fiat.name }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
+ ]
+ : []),
+ ]);
+ }
+ } catch (err) {
+ console.error('quote refresh sell', fiat.name, asset.id, pm, err.message);
+ }
+ }
+ }
+ }
+ const swapSrc = buyable[0];
+ const swapDst = buyable.find((a) => a.id !== (swapSrc && swapSrc.id));
+ if (swapSrc && swapDst) {
+ const body = { sourceAsset: { id: swapSrc.id }, targetAsset: { id: swapDst.id }, amount: 0.01 };
+ try {
+ const got = await httpJson('PUT', '/v1/swap/quote', body);
+ if (got.status === 200 && got.json) {
+ const rec = { json: got.json, at: Date.now() };
+ rememberQuote(swap, 'swap', rec, [
+ body,
+ { sourceAsset: { name: swapSrc.name }, targetAsset: { id: swapDst.id }, amount: 0.01 },
+ { sourceAsset: { id: swapSrc.id }, targetAsset: { name: swapDst.name }, amount: 0.01 },
+ { sourceAsset: { name: swapSrc.name }, targetAsset: { name: swapDst.name }, amount: 0.01 },
+ ]);
+ }
+ } catch (err) {
+ console.error('quote refresh swap', err.message);
+ }
+ }
+ for (const p of RAM_GET_PATHS) {
+ try {
+ const got = await httpJson('GET', p);
+ if (got.status === 200 && got.json) realunit.set(p, { json: got.json, at: Date.now() });
+ } catch (err) {
+ console.error('quote refresh realunit', p, err.message);
+ }
+ }
+ if (buy.size === 0 && sell.size === 0) {
+ console.error('quote book refresh: empty book, keeping previous');
+ return;
+ }
+ if (buy.size > 0) quoteBook.buy = buy;
+ if (sell.size > 0) quoteBook.sell = sell;
+ if (swap.size > 0) quoteBook.swap = swap;
+ if (realunit.size > 0) quoteBook.realunit = realunit;
+ quoteBook.filledAt = Date.now();
+ console.log('quote book buy', quoteBook.buy.size, 'sell', quoteBook.sell.size, 'ru', quoteBook.realunit.size);
+ } catch (err) {
+ console.error('quote book refresh', err.message);
+ }
+}
+
+async function refreshSwagger() {
+ try {
+ const got = await httpJson('GET', '/swagger-json');
+ if (!got.json || !got.json.paths) return;
+ const paths = {};
+ for (const [p, ops] of Object.entries(got.json.paths)) {
+ if (!isServedPath(p)) continue;
+ paths[p] = ops;
+ }
+ swaggerSpec = { ...got.json, paths, info: { ...(got.json.info || {}), title: 'DFX API' } };
+ console.log('swagger snapshot paths', Object.keys(paths).length);
+ } catch (err) {
+ console.error('swagger refresh', err.message);
+ }
+}
+
+function swaggerHtml() {
+ return `
+
DFX API
+
+
+
+
+
+
+`;
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ try {
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
+ } catch (err) {
+ reject(err);
+ }
+ });
+ req.on('error', reject);
+ });
+}
+
+function sendJson(res, status, body, via) {
+ let buf;
+ if (Buffer.isBuffer(body)) {
+ try {
+ buf = Buffer.from(JSON.stringify(JSON.parse(body.toString('utf8')), null, 2) + '\n');
+ } catch {
+ buf = body;
+ }
+ } else {
+ buf = Buffer.from(JSON.stringify(body, null, 2) + '\n');
+ }
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'content-length': buf.length,
+ 'x-content-type-options': 'nosniff',
+ 'x-front-api': via,
+ 'access-control-allow-origin': '*',
+ });
+ res.end(buf);
+}
+
+function highlightJson(obj) {
+ return JSON.stringify(obj, null, 2)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"(?:\\.|[^"\\])*"(?=\s*:)/g, '$&')
+ .replace(/: ("(?:\\.|[^"\\])*")/g, ': $1');
+}
+
+function sendVersion(req, res, obj, via) {
+ if (String(req.headers.accept || '').includes('text/html')) {
+ const html = Buffer.from(
+ '' +
+ '' +
+ highlightJson(obj) +
+ '
\n',
+ );
+ res.writeHead(200, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'content-length': html.length,
+ 'x-front-api': via,
+ });
+ res.end(html);
+ return;
+ }
+ sendJson(res, 200, obj, via);
+}
+
+function countryDto(row) {
+ return {
+ id: row.id,
+ symbol: row.symbol,
+ name: row.name,
+ foreignName: row.foreignName,
+ locationAllowed: !!row.ipEnable,
+ ibanAllowed: !!row.fatfEnable,
+ kycAllowed: !!row.dfxEnable,
+ kycOrganizationAllowed: !!row.dfxOrganizationEnable,
+ nationalityAllowed: !!row.nationalityStepEnable,
+ bankAllowed: !!(row.bankEnable && row.dfxEnable),
+ cardAllowed: !!(row.checkoutEnable && row.fatfEnable),
+ cryptoAllowed: !!row.cryptoEnable,
+ };
+}
+
+function languageDto(row) {
+ return {
+ id: row.id,
+ name: row.name,
+ symbol: row.symbol,
+ foreignName: row.foreignName,
+ enable: !!row.enable,
+ };
+}
+
+const DB_READ = {
+ '/v1/country': {
+ sql:
+ 'SELECT id, symbol, name, "foreignName", "ipEnable", "fatfEnable", "dfxEnable", ' +
+ '"dfxOrganizationEnable", "nationalityStepEnable", "bankEnable", "checkoutEnable", "cryptoEnable" ' +
+ 'FROM country ORDER BY id',
+ map: (rows) => rows.map(countryDto),
+ },
+ '/v1/language': {
+ sql: 'SELECT id, name, symbol, "foreignName", enable FROM language ORDER BY id',
+ map: (rows) => rows.map(languageDto),
+ },
+};
+
+async function tryDbRead(path) {
+ if (!pool) return null;
+ const spec = DB_READ[path];
+ if (!spec) return null;
+ const result = await pool.query(spec.sql);
+ return Buffer.from(JSON.stringify(spec.map(result.rows)));
+}
+
+function proxy(req, res, stale) {
+ const target = new URL(BACKEND);
+ const opts = {
+ hostname: target.hostname,
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
+ path: req.url,
+ method: req.method,
+ headers: { ...req.headers, host: target.host },
+ };
+ const p = http.request(opts, (up) => {
+ const chunks = [];
+ up.on('data', (c) => chunks.push(c));
+ up.on('end', () => {
+ const body = Buffer.concat(chunks);
+ const headers = { ...up.headers };
+ delete headers['transfer-encoding'];
+ if (isCacheable(req) && up.statusCode === 200) {
+ putCache(cacheKey(req), up.statusCode, headers, body);
+ headers['x-front-api'] = 'miss';
+ }
+ res.writeHead(up.statusCode, headers);
+ res.end(body);
+ });
+ });
+ p.on('error', (err) => {
+ console.error('proxy error', err.message);
+ if (stale && !res.headersSent) {
+ const headers = { ...stale.headers, 'x-front-api': 'stale' };
+ res.writeHead(stale.status, headers);
+ res.end(stale.body);
+ return;
+ }
+ if (!res.headersSent) {
+ res.writeHead(503, {
+ 'content-type': 'application/json',
+ 'retry-after': '30',
+ 'access-control-allow-origin': '*',
+ });
+ }
+ res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
+ });
+ attachRequestTimeout(p, 20000, () => {
+ p.destroy();
+ });
+ req.pipe(p);
+}
+
+const server = http.createServer((req, res) => {
+ const path = (req.url || '/').split('?')[0];
+ if (path === '/version' && req.method === 'GET') {
+ sendVersion(req, res, localVersion(), 'local');
+ return;
+ }
+
+ if ((path === '/swagger' || path === '/swagger/' || path === '/swagger-ui' || path === '/swagger-ui/') && req.method === 'GET') {
+ if (!swaggerSpec) {
+ sendJson(res, 503, { statusCode: 503, message: 'swagger snapshot empty' }, 'local');
+ return;
+ }
+ const html = Buffer.from(swaggerHtml());
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length, 'x-front-api': 'local' });
+ res.end(html);
+ return;
+ }
+
+ if ((path === '/swagger-json' || path === '/swagger-json/') && req.method === 'GET') {
+ if (!swaggerSpec) {
+ sendJson(res, 503, { statusCode: 503, message: 'swagger snapshot empty' }, 'local');
+ return;
+ }
+ sendJson(res, 200, swaggerSpec, 'local');
+ return;
+ }
+
+ const quoteKind =
+ path === '/v1/buy/quote' ? 'buy' : path === '/v1/sell/quote' ? 'sell' : path === '/v1/swap/quote' ? 'swap' : null;
+ if (quoteKind && req.method === 'PUT') {
+ readBody(req)
+ .then((body) => {
+ const stored = quoteBook[quoteKind].get(pairKey(quoteKind, body));
+ if (!isQuoteFresh(stored)) {
+ sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
+ return;
+ }
+ const scaled = scaleQuote(stored, body);
+ if (!scaled) {
+ sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
+ return;
+ }
+ sendJson(res, 200, scaled, 'ram');
+ })
+ .catch(() => sendJson(res, 400, { statusCode: 400, message: 'invalid json' }, 'local'));
+ return;
+ }
+
+ if (req.method === 'GET' && RAM_GET_PATHS.includes(path)) {
+ const hitRu = quoteBook.realunit.get(path);
+ if (!isQuoteFresh(hitRu)) {
+ sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
+ return;
+ }
+ sendJson(res, 200, hitRu.json, 'ram');
+ return;
+ }
+
+ const key = cacheKey(req);
+ const hit = isCacheable(req) ? getCached(key) : null;
+ const fresh = hit && Date.now() <= hit.exp ? hit : null;
+ if (fresh) {
+ const headers = { ...fresh.headers, 'x-front-api': 'hit' };
+ res.writeHead(fresh.status, headers);
+ res.end(fresh.body);
+ return;
+ }
+
+ const stale = hit && Date.now() > hit.exp ? hit : null;
+
+ if (pool && req.method === 'GET' && !req.headers.authorization && DB_READ[path]) {
+ tryDbRead(path)
+ .then((body) => {
+ if (!body) {
+ proxy(req, res, stale);
+ return;
+ }
+ putCache(key, 200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, body);
+ sendJson(res, 200, body, 'db');
+ })
+ .catch((err) => {
+ console.error('db-read', path, err.message);
+ if (stale) {
+ const headers = { ...stale.headers, 'x-front-api': 'stale' };
+ res.writeHead(stale.status, headers);
+ res.end(stale.body);
+ return;
+ }
+ proxy(req, res, stale);
+ });
+ return;
+ }
+
+ proxy(req, res, stale);
+});
+
+server.on('upgrade', (req, socket, head) => {
+ const target = new URL(BACKEND);
+ const port = +(target.port || (target.protocol === 'https:' ? 443 : 80));
+ const up = net.connect(port, target.hostname, () => {
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
+ const headers = { ...req.headers, host: target.host };
+ for (const [k, v] of Object.entries(headers)) {
+ if (v === undefined) continue;
+ if (Array.isArray(v)) {
+ for (const item of v) lines.push(`${k}: ${item}`);
+ } else {
+ lines.push(`${k}: ${v}`);
+ }
+ }
+ up.write(lines.join('\r\n') + '\r\n\r\n');
+ if (head && head.length) up.write(head);
+ up.pipe(socket);
+ socket.pipe(up);
+ });
+ up.on('error', () => socket.destroy());
+ socket.on('error', () => up.destroy());
+});
+
+if (require.main === module) {
+ server.listen(PORT, BIND, () => {
+ console.log(`front-api listening on ${BIND}:${PORT}` + (pool ? ' db-read on' : ''));
+ refreshSwagger();
+ setInterval(refreshSwagger, 10 * 60 * 1000).unref();
+ // Off by default.
+ if (process.env.QUOTE_BOOK_REFRESH === '1') {
+ refreshQuoteBook();
+ setInterval(refreshQuoteBook, 60 * 1000).unref();
+ } else {
+ console.log('quote book refresh disabled (QUOTE_BOOK_REFRESH=1 to enable)');
+ }
+ });
+}
+
+module.exports = {
+ QUOTE_TTL_MS,
+ quoteBook,
+ pairKey,
+ isQuoteFresh,
+ attachRequestTimeout,
+ server,
+};
diff --git a/test/test-server.sh b/test/test-server.sh
new file mode 100755
index 0000000..d6ba3f2
--- /dev/null
+++ b/test/test-server.sh
@@ -0,0 +1,178 @@
+#!/usr/bin/env bash
+# Pin test for server.js (RAM TTL + proxy timeout)
+#
+# Arms:
+# RAM miss → 503 ram_miss
+# RAM stale (older than QUOTE_TTL_MS) → 503 ram_stale
+# RAM fresh → 200 ram_fresh
+# swagger snapshot empty → 503 local body swagger_empty
+# attachRequestTimeout → callback + destroy proxy_timeout
+# poller default off (QUOTE_BOOK_REFRESH!==1) poller_off
+set -euo pipefail
+
+repo_root=$(cd "$(dirname "$0")/.." && pwd)
+server_js="$repo_root/server.js"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+[ -f "$server_js" ] || fail "missing: $server_js"
+grep -q "QUOTE_BOOK_REFRESH === '1'" "$server_js" || fail "poller_off: gate missing"
+grep -q 'refreshQuoteBook();' "$server_js" || fail "poller_off: refresh helper missing"
+grep -q 'function isServedPath' "$server_js" || fail "isServedPath missing"
+grep -q 'if (!isServedPath(p)) continue' "$server_js" || fail "swagger snapshot must allowlist served paths"
+if grep -q 'low.includes' "$server_js"; then
+ fail "swagger snapshot must not denylist unserved routes"
+fi
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+cat >"$tmp/run-tests.js" <<'JS'
+'use strict';
+
+const http = require('http');
+const net = require('net');
+
+const serverJs = process.argv[2];
+process.env.BACKEND_URL = 'http://127.0.0.1:9';
+delete process.env.SQL_HOST;
+
+const {
+ QUOTE_TTL_MS,
+ quoteBook,
+ pairKey,
+ isQuoteFresh,
+ attachRequestTimeout,
+ server,
+} = require(serverJs);
+
+function fail(msg) {
+ console.error('FAIL:', msg);
+ process.exit(1);
+}
+
+if (!(QUOTE_TTL_MS > 0)) fail('QUOTE_TTL_MS unset');
+if (!isQuoteFresh({ json: { ok: 1 }, at: Date.now() })) fail('isQuoteFresh: fresh should pass');
+if (isQuoteFresh({ json: { ok: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 })) fail('isQuoteFresh: stale should fail');
+if (isQuoteFresh(undefined)) fail('isQuoteFresh: miss should fail');
+
+function request(port, method, urlPath, body) {
+ return new Promise((resolve, reject) => {
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
+ const req = http.request(
+ {
+ hostname: '127.0.0.1',
+ port,
+ path: urlPath,
+ method,
+ headers: payload
+ ? { 'content-type': 'application/json', 'content-length': payload.length }
+ : {},
+ },
+ (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers });
+ });
+ },
+ );
+ req.on('error', reject);
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+async function main() {
+ await new Promise((resolve, reject) => {
+ server.listen(0, '127.0.0.1', resolve);
+ server.on('error', reject);
+ });
+ const port = server.address().port;
+ const ruPath = '/v1/realunit/quote/price';
+
+ quoteBook.realunit.clear();
+ let got = await request(port, 'GET', ruPath);
+ if (got.status !== 503) fail(`ram_miss: expected 503 got ${got.status}`);
+ if (!got.body.includes('quote unavailable')) fail('ram_miss: body');
+
+ quoteBook.realunit.set(ruPath, { json: { price: 1 }, at: Date.now() - QUOTE_TTL_MS - 1000 });
+ got = await request(port, 'GET', ruPath);
+ if (got.status !== 503) fail(`ram_stale: expected 503 got ${got.status}`);
+
+ quoteBook.realunit.set(ruPath, { json: { price: 42 }, at: Date.now() });
+ got = await request(port, 'GET', ruPath);
+ if (got.status !== 200) fail(`ram_fresh: expected 200 got ${got.status}`);
+ if (!got.body.includes('"price": 42') && !got.body.includes('"price":42')) fail('ram_fresh: body');
+ if (got.headers['x-front-api'] !== 'ram') fail('ram_fresh: x-front-api');
+
+ const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
+ const buyKey = pairKey('buy', buyBody);
+ quoteBook.buy.clear();
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 503) fail(`ram_miss buy: expected 503 got ${got.status}`);
+
+ quoteBook.buy.set(buyKey, {
+ json: { rate: 2, amount: 100, estimatedAmount: 50 },
+ at: Date.now() - QUOTE_TTL_MS - 1,
+ });
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 503) fail(`ram_stale buy: expected 503 got ${got.status}`);
+
+ quoteBook.buy.set(buyKey, {
+ json: { rate: 2, amount: 100, estimatedAmount: 50 },
+ at: Date.now(),
+ });
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 200) fail(`ram_fresh buy: expected 200 got ${got.status}`);
+
+ got = await request(port, 'GET', '/swagger-json');
+ if (got.status !== 503) fail(`swagger_empty json: expected 503 got ${got.status}`);
+ if (!got.body.includes('swagger snapshot empty')) fail('swagger_empty json: body');
+ if (got.headers['x-front-api'] !== 'local') fail('swagger_empty json: x-front-api');
+ got = await request(port, 'GET', '/swagger');
+ if (got.status !== 503) fail(`swagger_empty html: expected 503 got ${got.status}`);
+ if (!got.body.includes('swagger snapshot empty')) fail('swagger_empty html: body');
+
+ await new Promise((resolve, reject) => {
+ const held = [];
+ const hanging = net.createServer((s) => {
+ held.push(s);
+ });
+ hanging.listen(0, '127.0.0.1', () => {
+ const hPort = hanging.address().port;
+ const req = http.request({ hostname: '127.0.0.1', port: hPort, path: '/', method: 'GET' });
+ let timedOut = false;
+ attachRequestTimeout(req, 50, () => {
+ timedOut = true;
+ req.destroy();
+ });
+ req.on('error', () => {
+ for (const s of held) s.destroy();
+ hanging.close(() => {
+ if (!timedOut) reject(new Error('proxy_timeout: destroy without timeout callback'));
+ else resolve();
+ });
+ });
+ req.end();
+ });
+ hanging.on('error', reject);
+ });
+
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ console.log('ok front-api server.js');
+}
+
+main().catch((err) => {
+ console.error('FAIL:', err && err.message ? err.message : err);
+ process.exit(1);
+});
+JS
+
+node "$tmp/run-tests.js" "$server_js" || fail "node helper failed"
+echo "ok front-api server.js"
From 1e1e96055143f65b821af35194b29ff0871e0e79 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 09:53:37 +0200
Subject: [PATCH 02/15] 01a02b77 - Add CONTRIBUTING, REVIEW, and
main-from-develop gate (#4)
* Add CONTRIBUTING, REVIEW, and main-from-develop gate
* Align auto-release PR body and tighten main-from-develop pins
* Pin auto-release details close and EN/DE blank lines
* Ignore fork PRs when detecting an existing release PR
---
.github/workflows/auto-release-pr.yaml | 14 ++-
.github/workflows/main-from-develop.yml | 48 ++++++++
.github/workflows/test.yml | 2 +
CONTRIBUTING.md | 151 ++++++++++++++++++++++++
README.md | 4 +
REVIEW.md | 76 ++++++++++++
test/test-auto-release-pr.sh | 30 +++++
test/test-main-from-develop.sh | 58 +++++++++
8 files changed, 380 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/main-from-develop.yml
create mode 100644 CONTRIBUTING.md
create mode 100644 REVIEW.md
create mode 100644 test/test-auto-release-pr.sh
create mode 100644 test/test-main-from-develop.sh
diff --git a/.github/workflows/auto-release-pr.yaml b/.github/workflows/auto-release-pr.yaml
index 31e8b00..8fa13fd 100644
--- a/.github/workflows/auto-release-pr.yaml
+++ b/.github/workflows/auto-release-pr.yaml
@@ -29,7 +29,7 @@ jobs:
- name: Check for existing PR
id: check-pr
run: |
- PR_COUNT=$(gh pr list --base main --head develop --state open --json number --jq 'length')
+ PR_COUNT=$(gh pr list --base main --head develop --state open --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository == false)] | length')
echo "pr_exists=$([[ $PR_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -49,11 +49,17 @@ jobs:
COMMIT_COUNT: ${{ steps.check-diff.outputs.commit_count }}
run: |
printf '%s\n' \
- "## Automatic Release PR" \
+ "EN:" \
+ "Automatic release PR from develop onto main. Merging publishes the production image tag and is a human decision." \
+ "" \
+ "DE:" \
+ "Automatischer Release-PR von develop nach main. Der Merge veröffentlicht das Produktions-Image und ist eine menschliche Entscheidung." \
+ "" \
+ "" \
+ "Details
" \
"" \
"This PR was automatically created after changes were pushed to develop." \
"It is the only path from develop onto main for this repository." \
- "Merging it publishes the production image tag and is a human decision." \
"Do not merge until develop has been checked on the development hub." \
"" \
"**Commits:** ${COMMIT_COUNT} new commit(s)" \
@@ -62,6 +68,8 @@ jobs:
"- [ ] Review all changes" \
"- [ ] Verify CI passes" \
"- [ ] Approve and merge when ready for production" \
+ "" \
+ " " \
> /tmp/pr-body.md
gh pr create \
diff --git a/.github/workflows/main-from-develop.yml b/.github/workflows/main-from-develop.yml
new file mode 100644
index 0000000..8f931a2
--- /dev/null
+++ b/.github/workflows/main-from-develop.yml
@@ -0,0 +1,48 @@
+# PRs into main must come from this repository's develop branch.
+# GitHub has no native source-branch restriction; this job is the gate.
+# Do not add a YAML `if:` key (job or step): a skipped required check
+# counts as passing.
+
+name: Main source branch
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+ - edited
+ - labeled
+ - unlabeled
+
+permissions:
+ contents: read
+
+jobs:
+ only-develop:
+ name: Main only from develop
+ runs-on: ubuntu-latest
+ steps:
+ - name: Reject any head other than this repo's develop
+ env:
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ THIS_REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ if [ -z "$HEAD_REF" ] || [ -z "$HEAD_REPO" ] || [ -z "$THIS_REPO" ]; then
+ echo "::error::Missing pull_request head metadata; refusing to pass."
+ exit 1
+ fi
+ if [ "$HEAD_REPO" != "$THIS_REPO" ]; then
+ echo "::error::PRs into main must come from ${THIS_REPO}@develop, not a fork (${HEAD_REPO})."
+ exit 1
+ fi
+ if [ "$HEAD_REF" != "develop" ]; then
+ echo "::error::PRs into main must come from develop (got '${HEAD_REF}'). Merge into develop first."
+ exit 1
+ fi
+ echo "Head is ${THIS_REPO}@develop."
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3a1ff00..7eee84b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -17,3 +17,5 @@ jobs:
with:
node-version: "22"
- run: bash test/test-server.sh
+ - run: bash test/test-main-from-develop.sh
+ - run: bash test/test-auto-release-pr.sh
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7129369
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,151 @@
+# Contributing
+
+## Deviating From These Rules
+
+These guidelines are binding. A pull request that knowingly does not meet one of
+them must say so **explicitly in its description**, naming the rule it departs
+from and the reason. An undeclared deviation is not a discussion point — the
+pull request is rejected.
+
+A declared deviation is the reviewer's call: they may accept it or refuse it at
+their own discretion. Declaring one is not the same as being granted one.
+
+## Review
+
+Reviewers follow [REVIEW.md](REVIEW.md). A standard review starts with whether
+this file was applied fully and correctly.
+
+## Build & Test
+
+The required suite is the GitHub Actions job `test`. It runs
+`bash test/test-server.sh`, `bash test/test-main-from-develop.sh`, and
+`bash test/test-auto-release-pr.sh`. Employees are not required to run it
+locally; those three commands are the local equivalent.
+Draft pull requests still run `test`. This repository does not skip CI on
+drafts and has no `ci:full` label.
+
+```bash
+bash test/test-server.sh
+bash test/test-main-from-develop.sh
+bash test/test-auto-release-pr.sh
+```
+
+## Git & PRs
+
+- Branch from `develop`. Never commit directly to `develop` or `main`.
+- Feature branch names are short and unique. An 8-character work prefix plus a
+ topic is fine. `feat/` and `fix/` prefixes are not required.
+- Commit subjects: imperative mood, no trailing period.
+- Commits must be GitHub-verified (signed with the author's GitHub-mapped
+ identity). Do not rewrite already-pushed commits to re-sign.
+- Open feature pull requests as drafts against `develop`.
+- Squash-and-merge when merging to `develop` — the squash keeps only the PR
+ title.
+- Release PRs (`develop` → `main`) are created automatically — never open them
+ manually.
+- CI rejects a pull request into `main` unless its head is this repository's
+ `develop` branch (check name `Main only from develop`). The check also rejects
+ forks even if the branch is named `develop`.
+- Merging into `main` publishes the production image tag; that merge is a human
+ decision.
+- A pull request lands complete or it does not land. Findings raised in review
+ on your own pull request are fixed in that pull request — never deferred to a
+ follow-up unless the reviewer grants that in writing on the pull request.
+- A defect in code a pull request touches is reported with the same evidence
+ whether the change introduced it or it was already there. Fixing versus
+ deferring a pre-existing bug is a separate reviewer grant.
+
+## PR description form
+
+The visible summary is an EN/DE block, at most four sentences per language. The
+rest goes in ``. Labels stand alone on their line; leave a blank line
+between the languages; leave a blank line after `` so GitHub renders
+Markdown inside the details.
+
+```
+EN:
+English text
+
+DE:
+Deutscher Text
+
+
+Details
+
+Full explanation.
+
+
+```
+
+This is a public repository: English in commit messages, code comments, and the
+details body. The `DE:` block is the German summary only.
+
+## PR Completeness
+
+When applicable, every pull request must include:
+
+1. **Environment / image / workflow updates** when boot or the image is
+ affected (`BACKEND_URL`, `SQL_*`, `QUOTE_BOOK_REFRESH`, `CACHE_*`, `PORT`,
+ `BIND`, Dockerfile, `.github/workflows`).
+2. **A pin** in `test/test-server.sh` (`server.js` behaviour),
+ `test/test-main-from-develop.sh` (the main-source gate), and/or
+ `test/test-auto-release-pr.sh` (the automatic release-PR body) for every
+ behaviour the pull request changes.
+3. **Swagger allowlist** update when the set of paths this process answers
+ itself changes (`isServedPath`, `CACHE_PREFIXES`, RAM quote paths).
+4. **A note in the PR body** when the outward behaviour of this layer changes
+ (cache, 503 bodies, `x-front-api`, which paths are answered here versus
+ proxied, quote source). Do not name other repositories.
+
+Missing any applicable item = changes requested.
+
+## Before Merge
+
+- Remove merge markers, commented-out code, and stale comments.
+- Resolve TODO comments if possible.
+- No `console.log` on the production path. `console.error` and boot logs are
+ allowed (this process has no separate logger).
+- Code comments in English.
+
+## General Principles
+
+- **Clarity over cleverness** — readable code beats short but obscure
+ expressions.
+- **Consistency** — same patterns everywhere.
+- **Minimal changes** — do not rebuild what already works.
+- **No over-engineering** — do not add layers this process does not need.
+
+## This process
+
+- This process answers a **fixed** set of routes itself. Every other request is
+ forwarded to `BACKEND_URL` without this repository listing those routes.
+- The swagger snapshot is an **allowlist** of paths this process serves, not a
+ denylist.
+- Authenticated requests are never answered from the GET cache.
+- The quote poller is **off by default** (`QUOTE_BOOK_REFRESH=1` to enable).
+- Do not expose internals in responses (SQL credentials, backend hosts, or
+ other secrets).
+
+## Naming & code style
+
+- camelCase, American English, methods are verbs, positive boolean names.
+- Always `===`. Use `??`, not `||`. Guard clauses and early returns. Split
+ nested ternaries into named intermediates.
+- Trailing commas in multi-line literals.
+- No magic booleans. Configuration via environment variables, not hardcoded
+ values.
+
+## Public repository hygiene
+
+Never name private or internal repositories, internal hostnames, or
+infrastructure internals in code, comments, docs, commit messages, PR titles,
+PR bodies, or PR comments. Phrase generically ("the deployment environment",
+"the infrastructure config"). Functional workflow values such as `runs-on`
+labels are allowed; descriptive hostnames are not.
+
+## Testing
+
+Pin tests live under `test/`. A failure mode is tested at the lowest layer that
+can express it (here: the Node helper and/or grep pins, not a production HTTP
+round-trip). A behaviour change without a new or updated pin is incomplete even
+if CI is green.
diff --git a/README.md b/README.md
index 8db9cf5..96aa89d 100644
--- a/README.md
+++ b/README.md
@@ -17,3 +17,7 @@ Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS`, `CACHE_MAX`, `SQL_H
Push to `develop` publishes `dfxswiss/front-api:beta` and the git SHA. Push to `main` publishes `dfxswiss/front-api:latest` and the git SHA. After a successful push the workflow notifies the configured infrastructure repo (`DISPATCH_TOKEN` + `DISPATCH_REPO`). If those secrets are unset, the image is still published.
This repository does not describe a particular deployment environment.
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md). Reviewers follow [REVIEW.md](REVIEW.md).
diff --git a/REVIEW.md b/REVIEW.md
new file mode 100644
index 0000000..876c2c0
--- /dev/null
+++ b/REVIEW.md
@@ -0,0 +1,76 @@
+# Review
+
+This is the contract for a standard pull-request review in this repository. It
+is not a second copy of [CONTRIBUTING.md](CONTRIBUTING.md). Each item is pass
+or fail. Any fail keeps the pull request as a draft or on changes requested.
+
+## 1. CONTRIBUTING.md applied fully and correctly
+
+Every applicable rule in CONTRIBUTING.md is checked against the diff. A
+declared deviation names the rule and the reason; an undeclared deviation is
+fail. Declared is not granted — only the reviewer grants, in writing on the
+pull request.
+
+This item includes the EN/DE PR-body form and GitHub-verified commits.
+
+## 2. Required CI green on the head SHA
+
+Job `test` is `success` on **exactly this** SHA.
+
+- `skipped` does not count as green unless this repository documents that skip
+ as expected. Today: `test` is not skipped on drafts.
+- `cancelled` is not a test failure and also not evidence.
+- Image jobs (`front-api DEV` / `front-api PRD`) run on push to a branch, not
+ on feature pull requests. They are not a gate for PRs into `develop`.
+- For a release PR into `main`: `test` is green, and the development image tag
+ was already published from `develop`.
+- Job `Main only from develop` must be `success` on PRs into `main`.
+
+## 3. Target branch and release path
+
+Feature pull requests target `develop`. A pull request into `main` is valid
+only when the head is this repository's `develop`, and only as the automatic
+release PR. A manually opened PR into `main` is fail — that part is a
+reviewer check, not CI. Job `Main only from develop` enforces only that the
+head is this repository's `develop` (same repository, not a fork). It cannot
+tell an automatic release PR from a manual one.
+
+## 4. Mergeable, no conflicts
+
+The pull request merges cleanly against its base.
+
+## 5. Public-repository hygiene
+
+The diff, commit messages, PR title, body, and comments obey the public
+repository hygiene rule in CONTRIBUTING.md.
+
+## 6. Tests cover the change
+
+New or changed branches in `server.js` (503 vs 200, cache hit/miss, allowlist,
+timeout, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
+changes have a pin in `test/test-main-from-develop.sh`. Automatic release-PR
+body-form changes have a pin in `test/test-auto-release-pr.sh`. Green CI
+without a pin for a behaviour change is fail.
+
+## 7. Secrets and boot config
+
+No secrets in the repository. A new environment variable read at boot is named
+in README.md. If the live value is missing in the deployment environment, that
+is a blocker — name no environment.
+
+## 8. Image and workflow changes
+
+Dockerfile, workflows, and tags `:beta` / `:latest` / SHA stay on the existing
+pattern (`develop` → `:beta`, `main` → `:latest`, notify via `DISPATCH_TOKEN` /
+`DISPATCH_REPO` when set). No silent change of `runs-on`, secret names, or
+dispatch payload.
+
+## 9. Scope
+
+Only what the pull request claims. No drive-by cleanup that violates
+CONTRIBUTING.md or lands untested.
+
+## 10. Outward behaviour of this layer
+
+If cache, 503 body, `x-front-api`, self-answered paths, or quote source change:
+it is said in the PR body, a pin is present, and the swagger allowlist matches.
diff --git a/test/test-auto-release-pr.sh b/test/test-auto-release-pr.sh
new file mode 100644
index 0000000..f74af98
--- /dev/null
+++ b/test/test-auto-release-pr.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Pin test for .github/workflows/auto-release-pr.yaml PR body form
+set -euo pipefail
+
+repo_root=$(cd "$(dirname "$0")/.." && pwd)
+wf="$repo_root/.github/workflows/auto-release-pr.yaml"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+[ -f "$wf" ] || fail "missing: $wf"
+grep -q '^ "EN:" \\$' "$wf" || fail "EN: label missing"
+grep -q '^ "DE:" \\$' "$wf" || fail "DE: label missing"
+en_block=$(grep -A3 '^ "EN:" \\$' "$wf" || true)
+echo "$en_block" | grep -q '^ "" \\$' || fail "blank line between EN and DE missing"
+echo "$en_block" | grep -q '^ "DE:" \\$' || fail "DE: not in the EN block"
+grep -q '' "$wf" || fail "details missing"
+grep -q 'Details
' "$wf" || fail "summary missing"
+grep -q ' ' "$wf" || fail "closing details missing"
+grep -A1 'Details
' "$wf" | grep -q '^ "" \\$' || fail "blank line after missing"
+grep -q 'isCrossRepository' "$wf" || fail "same-repo filter missing on existing-PR search"
+grep -Fq 'select(.isCrossRepository == false)' "$wf" || fail "fork PRs must not count as the release PR"
+grep -q 'gh pr create' "$wf" || fail "gh pr create missing"
+grep -q -- '--draft' "$wf" || fail "draft flag missing"
+grep -q -- '--base main' "$wf" || fail "base main missing"
+grep -q -- '--head develop' "$wf" || fail "head develop missing"
+
+echo "ok front-api auto-release-pr"
diff --git a/test/test-main-from-develop.sh b/test/test-main-from-develop.sh
new file mode 100644
index 0000000..43ffb50
--- /dev/null
+++ b/test/test-main-from-develop.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Pin test for .github/workflows/main-from-develop.yml
+#
+# Arms:
+# job name is Main only from develop job_name
+# pull_request into main trigger_main
+# types include edited (base retarget) trigger_types
+# HEAD_REF / HEAD_REPO / THIS_REPO from event env_vars
+# empty metadata refuses to pass empty_meta
+# fork identity compared (HEAD_REPO vs THIS_REPO) fork_check
+# branch compared (HEAD_REF vs develop) develop_check
+# mismatch exits 1 fail_closed
+# runs-on ubuntu-latest runner
+# no YAML if: key (skipped required check = pass) no_skip_if
+# no continue-on-error (failed step still green) no_continue
+set -euo pipefail
+
+repo_root=$(cd "$(dirname "$0")/.." && pwd)
+wf="$repo_root/.github/workflows/main-from-develop.yml"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+[ -f "$wf" ] || fail "missing: $wf"
+grep -q 'name: Main only from develop' "$wf" || fail "job_name: Main only from develop missing"
+grep -q 'pull_request:' "$wf" || fail "trigger_main: pull_request missing"
+grep -q '^ - main$' "$wf" || fail "trigger_main: branches main missing"
+for t in opened synchronize reopened ready_for_review edited labeled unlabeled; do
+ grep -q "^ - ${t}$" "$wf" || fail "trigger_types: ${t} missing"
+done
+grep -q 'HEAD_REF:' "$wf" || fail "env_vars: HEAD_REF missing"
+grep -q 'HEAD_REPO:' "$wf" || fail "env_vars: HEAD_REPO missing"
+grep -q 'THIS_REPO:' "$wf" || fail "env_vars: THIS_REPO missing"
+grep -q 'github.event.pull_request.head.ref' "$wf" || fail "env_vars: head.ref expression missing"
+grep -q 'github.event.pull_request.head.repo.full_name' "$wf" || fail "env_vars: head.repo.full_name missing"
+grep -q 'github.repository' "$wf" || fail "env_vars: github.repository missing"
+grep -q 'Missing pull_request head metadata' "$wf" || fail "empty_meta: refusal message missing"
+grep -q 'z "$HEAD_REF"' "$wf" || fail "empty_meta: HEAD_REF empty check missing"
+grep -q 'z "$HEAD_REPO"' "$wf" || fail "empty_meta: HEAD_REPO empty check missing"
+grep -q 'z "$THIS_REPO"' "$wf" || fail "empty_meta: THIS_REPO empty check missing"
+grep -Fq 'z "$HEAD_REF" ] || [ -z "$HEAD_REPO" ] || [ -z "$THIS_REPO"' "$wf" || fail "empty_meta: empty checks must be OR-combined"
+grep -q 'HEAD_REPO" != "$THIS_REPO"' "$wf" || fail "fork_check: HEAD_REPO vs THIS_REPO missing"
+grep -q 'not a fork' "$wf" || fail "fork_check: fork error message missing"
+grep -q 'HEAD_REF" != "develop"' "$wf" || fail "develop_check: HEAD_REF vs develop missing"
+grep -q "PRs into main must come from develop" "$wf" || fail "develop_check: non-develop error message missing"
+exits=$(grep -c 'exit 1' "$wf" || true)
+[ "$exits" -ge 3 ] || fail "fail_closed: expected >=3 exit 1 paths, got ${exits}"
+grep -q 'runs-on: ubuntu-latest' "$wf" || fail "runner: ubuntu-latest missing"
+if grep -E '^[[:space:]]+if:' "$wf"; then
+ fail "no_skip_if: YAML if: key would skip a required check"
+fi
+if grep -E '^[[:space:]]+continue-on-error:' "$wf"; then
+ fail "no_continue: continue-on-error would keep a failed gate green"
+fi
+
+echo "ok front-api main-from-develop"
From 9a8b41be0a40bd4db9a47814e65410f383b43b42 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:25:31 +0200
Subject: [PATCH 03/15] 01a02b77 - Require 100% coverage and frontend E2E for
offered routes (#5)
* Require 100% c8 coverage on all production JavaScript
* Harden coverage pins, lockfile image install, and poller-off child
* Move coverage gate pins out of the self-grep script
* Cover boot error exit and drop unused os import
* Pin REQUEST_TIMEOUT_MS env read and default
* Require a usage and frontend E2E catalog for every offered route
* Stop treating placeholder catalog pointers as usage or E2E
* Drop non-frontend catalog pointers for version swagger and asset smoke
* Point widget E2E at services and allowlist public consumer repos
* Reject mixed unidentified pointers and require exact GET names
---
.c8rc.json | 10 +
.dockerignore | 2 +
.github/workflows/test.yml | 2 +
.gitignore | 1 +
CONTRIBUTING.md | 52 +-
Dockerfile | 4 +-
README.md | 5 +-
REVIEW.md | 43 +-
offered-routes.json | 467 +++++++++++++
package-lock.json | 1237 +++++++++++++++++++++++++++++++++++
package.json | 7 +
server.js | 124 +++-
test/offered-routes.test.js | 114 ++++
test/preload-pg-throw.js | 8 +
test/run-main-coverage.sh | 9 +
test/server.test.js | 907 +++++++++++++++++++++++++
test/test-offered-routes.sh | 37 ++
test/test-server.sh | 183 ++----
18 files changed, 3028 insertions(+), 184 deletions(-)
create mode 100644 .c8rc.json
create mode 100644 offered-routes.json
create mode 100644 package-lock.json
create mode 100644 test/offered-routes.test.js
create mode 100644 test/preload-pg-throw.js
create mode 100644 test/run-main-coverage.sh
create mode 100644 test/server.test.js
create mode 100755 test/test-offered-routes.sh
diff --git a/.c8rc.json b/.c8rc.json
new file mode 100644
index 0000000..f87916b
--- /dev/null
+++ b/.c8rc.json
@@ -0,0 +1,10 @@
+{
+ "all": true,
+ "include": ["**/*.js"],
+ "exclude": ["test/**", "coverage/**", "node_modules/**"],
+ "temp-directory": "coverage/tmp",
+ "lines": 100,
+ "functions": 100,
+ "branches": 100,
+ "statements": 100
+}
diff --git a/.dockerignore b/.dockerignore
index 281e557..9b12780 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,5 @@
node_modules
.git
.env
+coverage
+test
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7eee84b..8f51c3a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,6 +16,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
+ - run: npm ci
- run: bash test/test-server.sh
+ - run: bash test/test-offered-routes.sh
- run: bash test/test-main-from-develop.sh
- run: bash test/test-auto-release-pr.sh
diff --git a/.gitignore b/.gitignore
index 5a00c13..95df82b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules/
.env
.DS_Store
+coverage/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7129369..dc8024d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,19 +17,30 @@ this file was applied fully and correctly.
## Build & Test
-The required suite is the GitHub Actions job `test`. It runs
-`bash test/test-server.sh`, `bash test/test-main-from-develop.sh`, and
+The required suite is the GitHub Actions job `test`. It runs `npm ci`, then
+`bash test/test-server.sh` (behaviour pins **and** the 100% coverage gate),
+`bash test/test-offered-routes.sh` (usage catalog vs the served-path
+allowlist), `bash test/test-main-from-develop.sh`, and
`bash test/test-auto-release-pr.sh`. Employees are not required to run it
-locally; those three commands are the local equivalent.
-Draft pull requests still run `test`. This repository does not skip CI on
-drafts and has no `ci:full` label.
+locally; those four commands are the local equivalent (`npm ci` first if `c8`
+is missing). Draft pull requests still run `test`. This repository does not
+skip CI on drafts and has no `ci:full` label.
```bash
bash test/test-server.sh
+bash test/test-offered-routes.sh
bash test/test-main-from-develop.sh
bash test/test-auto-release-pr.sh
```
+Every production JavaScript file must stay at **100% statement, branch,
+function and line coverage**. CI enforces this with `c8 --check-coverage`
+(see `.c8rc.json`: `--all --include='**/*.js' --exclude='test/**' --exclude='coverage/**' --exclude='node_modules/**'`,
+and 100 on all four metrics). A result below 100% on any metric turns the
+`test` job red. `--all` plus that include/exclude pulls every new `*.js` file
+outside `test/` into the report at 0% until tests exist: adding a script
+without tests fails CI. Production code must not be listed in `--exclude`.
+
## Git & PRs
- Branch from `develop`. Never commit directly to `develop` or `main`.
@@ -85,17 +96,28 @@ details body. The `DE:` block is the German summary only.
When applicable, every pull request must include:
1. **Environment / image / workflow updates** when boot or the image is
- affected (`BACKEND_URL`, `SQL_*`, `QUOTE_BOOK_REFRESH`, `CACHE_*`, `PORT`,
- `BIND`, Dockerfile, `.github/workflows`).
+ affected (`BACKEND_URL`, `SQL_*`, `QUOTE_BOOK_REFRESH`, `CACHE_*`,
+ `REQUEST_TIMEOUT_MS`, `FRONT_API_EXIT_AFTER_BOOT`, `PORT`, `BIND`,
+ Dockerfile, `.github/workflows`).
2. **A pin** in `test/test-server.sh` (`server.js` behaviour),
`test/test-main-from-develop.sh` (the main-source gate), and/or
`test/test-auto-release-pr.sh` (the automatic release-PR body) for every
behaviour the pull request changes.
3. **Swagger allowlist** update when the set of paths this process answers
itself changes (`isServedPath`, `CACHE_PREFIXES`, RAM quote paths).
-4. **A note in the PR body** when the outward behaviour of this layer changes
+4. **`offered-routes.json`** update for every path this process answers
+ itself: a `usedIn` pointer (public consumer repo + file, or
+ `unidentified: true` with a note) and an `e2e` pointer
+ (frontend-inclusive E2E in a public repo + file, or `unidentified: true`
+ with a note). CI checks the catalog is complete and the fields are
+ present. CI does **not** run foreign E2E suites — reviewers do, per
+ [REVIEW.md](REVIEW.md). The E2E need not live on that other repository's
+ default branch. An `unidentified` row is catalog-complete for CI and is
+ **not** a grant to change that path. Private repositories are not named.
+5. **A note in the PR body** when the outward behaviour of this layer changes
(cache, 503 bodies, `x-front-api`, which paths are answered here versus
- proxied, quote source). Do not name other repositories.
+ proxied, quote source). Do not name private repositories. Public consumer
+ paths belong in `offered-routes.json`.
Missing any applicable item = changes requested.
@@ -149,3 +171,15 @@ Pin tests live under `test/`. A failure mode is tested at the lowest layer that
can express it (here: the Node helper and/or grep pins, not a production HTTP
round-trip). A behaviour change without a new or updated pin is incomplete even
if CI is green.
+
+There is no production JavaScript in this repository that may ship below 100%
+coverage. The coverage gate is the CI job, not a review courtesy.
+
+Every path this process answers itself also needs **frontend E2E** coverage:
+a real UI flow that hits that function, listed in `offered-routes.json`.
+Those tests usually live in the consumer repository (for example
+`DFXswiss/services` `e2e-stack/specs/buy.spec.ts`). This repository's CI
+enforces the catalog, not the foreign suite. A mocked API intercept that
+never reaches this process is not E2E of this layer. Reviewers must not
+merge a change to an offered function until that E2E exists (any branch of
+the named public repo).
diff --git a/Dockerfile b/Dockerfile
index f19475b..0f2262e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM node:22-alpine
WORKDIR /app
-COPY package.json server.js ./
-RUN npm install --omit=dev
+COPY package.json package-lock.json server.js ./
+RUN npm ci --omit=dev
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 96aa89d..c16fa0b 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Public HTTP layer in front of the DFX backend. This process answers a fixed set
BACKEND_URL=http://127.0.0.1:3000 node server.js
```
-Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS`, `CACHE_MAX`, `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`, `QUOTE_BOOK_REFRESH` (`1` to enable the quote poller; off by default).
+Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS`, `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`, `QUOTE_BOOK_REFRESH` (`1` to enable the quote poller; off by default). `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
## Images
@@ -21,3 +21,6 @@ This repository does not describe a particular deployment environment.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Reviewers follow [REVIEW.md](REVIEW.md).
+Every path this process answers itself is listed in [offered-routes.json](offered-routes.json)
+with a public usage pointer and a frontend E2E pointer, or `unidentified: true`
+plus a note when none is named.
diff --git a/REVIEW.md b/REVIEW.md
index 876c2c0..80db01f 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -15,7 +15,10 @@ This item includes the EN/DE PR-body form and GitHub-verified commits.
## 2. Required CI green on the head SHA
-Job `test` is `success` on **exactly this** SHA.
+Job `test` is `success` on **exactly this** SHA. That job includes the 100%
+coverage gate (`c8 --check-coverage` on all four metrics) and the offered-route
+catalog check (`test/test-offered-routes.sh`). A coverage miss or a catalog
+miss is a red job, not a review note.
- `skipped` does not count as green unless this repository documents that skip
as expected. Today: `test` is not skipped on drafts.
@@ -50,7 +53,9 @@ New or changed branches in `server.js` (503 vs 200, cache hit/miss, allowlist,
timeout, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
changes have a pin in `test/test-main-from-develop.sh`. Automatic release-PR
body-form changes have a pin in `test/test-auto-release-pr.sh`. Green CI
-without a pin for a behaviour change is fail.
+without a pin for a behaviour change is fail. Every production `*.js` file
+must report 100% statements, branches, functions and lines; a new script
+under the coverage include that is untested fails CI.
## 7. Secrets and boot config
@@ -74,3 +79,37 @@ CONTRIBUTING.md or lands untested.
If cache, 503 body, `x-front-api`, self-answered paths, or quote source change:
it is said in the PR body, a pin is present, and the swagger allowlist matches.
+
+## 11. Usage catalog and frontend E2E
+
+[offered-routes.json](offered-routes.json) lists every path this process
+answers itself. Each row has `usedIn` and `e2e`. A pointer is either a
+public repo + file, or `unidentified: true` plus a note. CI already fails
+when a served path has no row or a row has empty fields. That is not
+enough to merge.
+
+- Fail if the pull request adds, removes, or changes how a self-answered
+ path answers (status, body, cache, quote source, allowlist) and that
+ row's `e2e` is `unidentified` **or** the named E2E does not actually
+ cover that function **including the frontend**.
+- The E2E may live in another public repository. It need not be on that
+ repository's default branch. This repository's CI does **not** run those
+ suites — the reviewer opens the named file (or the named branch / pull
+ request) and checks it.
+- A test that mocks the API and never reaches this process is fail.
+- A unit or widget test without a UI flow through the real endpoint is
+ fail for this item (it may still be a valid pin in the consumer). Do
+ not list those files as `e2e`.
+- Naming a private repository in the catalog, the diff, or the pull
+ request is fail (item 5). Private consumers are a generic note, not a
+ `repo` field.
+- `unidentified` documents a gap. It is not a consumer and not E2E.
+ Changing that path still needs a real pointer, or a written grant on
+ the pull request.
+- Unchanged catalog rows this pull request does not touch: a weak or
+ unidentified E2E is still reported; deferring it needs a written grant
+ on the pull request.
+
+Any fail on this item keeps the pull request as a draft or on changes
+requested. There is no "follow-up E2E" for a new or changed offered
+function unless the reviewer grants that in writing.
diff --git a/offered-routes.json b/offered-routes.json
new file mode 100644
index 0000000..636572d
--- /dev/null
+++ b/offered-routes.json
@@ -0,0 +1,467 @@
+{
+ "title": "Offered routes: usage and frontend E2E",
+ "rules": "Every path this process answers itself has a row. usedIn is a public consumer repo+file, or unidentified:true with a note when no public call site is named. e2e is a frontend-inclusive E2E in a public repo (any branch), or unidentified:true when none is named. This repo's CI checks fields only and does not run foreign suites. Private repositories are not named. Proxied backend routes are not listed.",
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "isServedPath and isCacheable: unauthenticated GET / may be answered from the GET cache, otherwise proxied. No dedicated public frontend call site named."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that asserts GET / of this process. Required before changing this path."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/version",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/compose.yml",
+ "note": "e2e-stack compose healthcheck is GET /version."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "compose.yml usedIn is a healthcheck, not a frontend UI flow. A frontend E2E that hits GET /version is required before changing this path."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/swagger",
+ "match": "exact",
+ "aliases": ["/swagger/", "/swagger-ui", "/swagger-ui/"],
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "This process serves the filtered swagger UI HTML. No separate public consumer call site named."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "Swagger HTML is this process's own UI. No separate consumer E2E named that opens /swagger in a browser. Required before changing swagger HTML behaviour."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/swagger-json",
+ "match": "exact",
+ "aliases": ["/swagger-json/"],
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "Allowlist snapshot loaded by the swagger UI this process serves. No separate public consumer call site named."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No consumer E2E named that fetches /swagger-json in a browser. Required before changing the snapshot."
+ }
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/v1/buy/quote",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/core/src/client/BuyApi.ts",
+ "note": "BuyApi.quote PUTs BuyUrl.quote (buy/quote) without a session token."
+ },
+ {
+ "repo": "DFXswiss/dfx-wallet",
+ "path": "src/features/dfx-backend/services/payment-service.ts",
+ "note": "Wallet payment service PUTs /v1/buy/quote."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "DFXswiss/services e2e-stack/specs/buy.spec.ts exercises PUT /v1/buy/paymentInfos, not this RAM quote path. A frontend E2E that PUTs /v1/buy/quote is required before changing this path."
+ }
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/v1/sell/quote",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/core/src/client/SellApi.ts",
+ "note": "SellApi.quote PUTs SellUrl.quote (sell/quote)."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "DFXswiss/services e2e-stack/specs/sell-swap.spec.ts is the widget sell frontend; it has not been shown to PUT /v1/sell/quote (vs payment-info paths). A frontend E2E that PUTs this path is required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/v1/swap/quote",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/core/src/client/SwapApi.ts",
+ "note": "SwapApi.quote PUTs SwapUrl.quote (swap/quote)."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "A frontend E2E that PUTs /v1/swap/quote is required before changing this path."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/asset",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/asset.hook.ts",
+ "note": "GET AssetUrl.get (asset)."
+ },
+ {
+ "repo": "DFXswiss/services",
+ "path": "src/screens/buy.screen.tsx",
+ "note": "Widget buy screen loads assets through @dfx.swiss/react."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/specs/buy.spec.ts",
+ "note": "Widget buy flow: frontend Playwright asserts against GET /v1/asset."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/fiat",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/fiat.hook.ts",
+ "note": "GET FiatUrl.get (fiat)."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_fiat_service.dart",
+ "note": "Wallet GET /v1/fiat."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/specs/buy.spec.ts",
+ "note": "Widget buy flow: frontend Playwright checks GET /v1/fiat."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/country",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/country.hook.ts",
+ "note": "GET CountryUrl.get (country)."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_country_service.dart",
+ "note": "Wallet GET /v1/country."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/specs/buy.spec.ts",
+ "note": "Widget buy flow: frontend Playwright loads public lists including country."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/language",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/language.hook.ts",
+ "note": "GET LanguageUrl.get (language)."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_language_service.dart",
+ "note": "Wallet GET /v1/language."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/specs/buy.spec.ts",
+ "note": "Widget buy flow: frontend Playwright loads GET /v1/language."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/statistic",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages or DFXswiss/services."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/statistic live. Required before changing this prefix."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/coin",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages or DFXswiss/services."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/coin live. Required before changing this prefix."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/setting",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/settings.hook.ts",
+ "note": "GET SettingsUrl.infoBanner (setting/infoBanner)."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "Widget specs intercept GET /v1/setting/infoBanner; that is not E2E of this layer. A live frontend E2E is required before changing this prefix."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/bank",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/bank.hook.ts",
+ "note": "GET BankUrl.get (bank)."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs /v1/bank live through the UI. Required before changing this prefix."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/app",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/app live. Required before changing this prefix."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/quote/buyPrice",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. RealUnitCH/app calls the parallel /v1/realunit/brokerbot/buyPrice, not this /quote/ path."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/quote/buyShares",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. RealUnitCH/app calls /v1/realunit/brokerbot/buyShares, not this /quote/ path."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/quote/info",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. No named public call site in RealUnitCH/app or DFXswiss/services."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/quote/price",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. No named public call site in RealUnitCH/app or DFXswiss/services."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/brokerbot/buyPrice",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_brokerbot_service.dart",
+ "note": "DfxBrokerbotService._buyPricePath."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart",
+ "note": "Buy converter calls getBuyPrice."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "RealUnitCH/app has widget/golden tests for the buy page, not a Maestro/device E2E that hits this path live. Required before changing this path."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/brokerbot/buyShares",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_brokerbot_service.dart",
+ "note": "DfxBrokerbotService._buySharesPath."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart",
+ "note": "Buy converter calls getBuyShares."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "Widget/golden tests exist; Maestro/device E2E that hits this path live is required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/brokerbot/info",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. RealUnitCH/app brokerbot client does not call /brokerbot/info."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/realunit/brokerbot/price",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "RAM GET. RealUnitCH/app brokerbot client does not call /brokerbot/price."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that GETs this path live. Required before changing it."
+ }
+ ]
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..faa2c4c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1237 @@
+{
+ "name": "dfx-front-api",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dfx-front-api",
+ "version": "0.1.0",
+ "dependencies": {
+ "pg": "^8.16.3"
+ },
+ "devDependencies": {
+ "c8": "^10.1.3"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
+ "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
+ "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/c8": {
+ "version": "10.1.3",
+ "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
+ "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.1",
+ "@istanbuljs/schema": "^0.1.3",
+ "find-up": "^5.0.0",
+ "foreground-child": "^3.1.1",
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.1.6",
+ "test-exclude": "^7.0.1",
+ "v8-to-istanbul": "^9.0.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "c8": "bin/c8.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "monocart-coverage-reports": "^2"
+ },
+ "peerDependenciesMeta": {
+ "monocart-coverage-reports": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 8a9e7e2..0ca06d9 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,14 @@
"private": true,
"version": "0.1.0",
"main": "server.js",
+ "scripts": {
+ "test": "bash test/test-server.sh && bash test/test-offered-routes.sh && bash test/test-main-from-develop.sh && bash test/test-auto-release-pr.sh",
+ "coverage:report": "c8 --check-coverage report"
+ },
"dependencies": {
"pg": "^8.16.3"
+ },
+ "devDependencies": {
+ "c8": "^10.1.3"
}
}
diff --git a/server.js b/server.js
index 879b008..b798373 100644
--- a/server.js
+++ b/server.js
@@ -4,16 +4,26 @@ const http = require('http');
const net = require('net');
const { URL } = require('url');
+function orFallback(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ return value;
+}
+
+function backendPortFor(target) {
+ return +(orFallback(target.port, target.protocol === 'https:' ? '443' : '80'));
+}
+
if (!process.env.BACKEND_URL) {
console.error('BACKEND_URL required');
process.exit(1);
}
-const PORT = +(process.env.PORT || 3000);
-const BIND = process.env.BIND || '0.0.0.0';
+const PORT = +(orFallback(process.env.PORT, 3000));
+const BIND = orFallback(process.env.BIND, '0.0.0.0');
const BACKEND = process.env.BACKEND_URL;
-const TTL_MS = +(process.env.CACHE_TTL_MS || 15000);
+const TTL_MS = +(orFallback(process.env.CACHE_TTL_MS, 15000));
const QUOTE_TTL_MS = 300000;
-const CACHE_MAX = +(process.env.CACHE_MAX || 500);
+const CACHE_MAX = +(orFallback(process.env.CACHE_MAX, 500));
+const REQUEST_TIMEOUT_MS = +(orFallback(process.env.REQUEST_TIMEOUT_MS, 20000));
const STARTED = new Date().toISOString();
// Public GET prefixes this layer may answer from cache. Authenticated
@@ -99,7 +109,7 @@ function httpJson(method, urlPath, body) {
const req = http.request(
{
hostname: target.hostname,
- port: target.port || 80,
+ port: backendPortFor(target),
path: urlPath,
method,
headers: payload
@@ -120,7 +130,7 @@ function httpJson(method, urlPath, body) {
},
);
req.on('error', reject);
- attachRequestTimeout(req, 20000, () => {
+ attachRequestTimeout(req, REQUEST_TIMEOUT_MS, () => {
req.destroy();
reject(new Error('timeout'));
});
@@ -155,22 +165,22 @@ const RAM_GET_PATHS = [
'/v1/realunit/brokerbot/price',
];
+const EXACT_GET_PATHS = [
+ '/',
+ '/version',
+ '/swagger',
+ '/swagger/',
+ '/swagger-json',
+ '/swagger-json/',
+ '/swagger-ui',
+ '/swagger-ui/',
+];
+
+const EXACT_PUT_PATHS = ['/v1/buy/quote', '/v1/sell/quote', '/v1/swap/quote'];
+
function isServedPath(path) {
const p = (path || '/').split('?')[0];
- if (
- p === '/' ||
- p === '/version' ||
- p === '/swagger' ||
- p === '/swagger/' ||
- p === '/swagger-json' ||
- p === '/swagger-json/' ||
- p === '/swagger-ui' ||
- p === '/swagger-ui/'
- ) {
- return true;
- }
- if (p === '/v1/buy/quote' || p === '/v1/sell/quote' || p === '/v1/swap/quote') return true;
- if (RAM_GET_PATHS.includes(p)) return true;
+ if (EXACT_GET_PATHS.includes(p) || EXACT_PUT_PATHS.includes(p) || RAM_GET_PATHS.includes(p)) return true;
return CACHE_PREFIXES.some((pref) => p === pref || p.startsWith(pref + '/'));
}
@@ -454,6 +464,7 @@ async function tryDbRead(path) {
const spec = DB_READ[path];
if (!spec) return null;
const result = await pool.query(spec.sql);
+ if (!result || !result.rows) return null;
return Buffer.from(JSON.stringify(spec.map(result.rows)));
}
@@ -461,7 +472,7 @@ function proxy(req, res, stale) {
const target = new URL(BACKEND);
const opts = {
hostname: target.hostname,
- port: target.port || (target.protocol === 'https:' ? 443 : 80),
+ port: backendPortFor(target),
path: req.url,
method: req.method,
headers: { ...req.headers, host: target.host },
@@ -498,7 +509,7 @@ function proxy(req, res, stale) {
}
res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
});
- attachRequestTimeout(p, 20000, () => {
+ attachRequestTimeout(p, REQUEST_TIMEOUT_MS, () => {
p.destroy();
});
req.pipe(p);
@@ -602,7 +613,7 @@ const server = http.createServer((req, res) => {
server.on('upgrade', (req, socket, head) => {
const target = new URL(BACKEND);
- const port = +(target.port || (target.protocol === 'https:' ? 443 : 80));
+ const port = backendPortFor(target);
const up = net.connect(port, target.hostname, () => {
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
const headers = { ...req.headers, host: target.host };
@@ -623,12 +634,11 @@ server.on('upgrade', (req, socket, head) => {
socket.on('error', () => up.destroy());
});
-if (require.main === module) {
+function boot() {
server.listen(PORT, BIND, () => {
console.log(`front-api listening on ${BIND}:${PORT}` + (pool ? ' db-read on' : ''));
refreshSwagger();
setInterval(refreshSwagger, 10 * 60 * 1000).unref();
- // Off by default.
if (process.env.QUOTE_BOOK_REFRESH === '1') {
refreshQuoteBook();
setInterval(refreshQuoteBook, 60 * 1000).unref();
@@ -638,11 +648,75 @@ if (require.main === module) {
});
}
+function maybeExitAfterBoot() {
+ if (process.env.FRONT_API_EXIT_AFTER_BOOT !== '1') return false;
+ server.once('listening', () => {
+ setTimeout(() => process.exit(0), 200);
+ });
+ server.once('error', () => process.exit(1));
+ return true;
+}
+
+if (require.main === module) {
+ maybeExitAfterBoot();
+ boot();
+}
+
+function setSwaggerSpec(value) {
+ swaggerSpec = value;
+}
+
+function getSwaggerSpec() {
+ return swaggerSpec;
+}
+
+function setPool(value) {
+ pool = value;
+}
+
+function getPool() {
+ return pool;
+}
+
module.exports = {
+ orFallback,
+ backendPortFor,
QUOTE_TTL_MS,
+ CACHE_MAX,
+ CACHE_PREFIXES,
+ RAM_GET_PATHS,
+ EXACT_GET_PATHS,
+ EXACT_PUT_PATHS,
quoteBook,
+ cache,
pairKey,
isQuoteFresh,
+ isServedPath,
+ isCacheable,
+ cacheKey,
+ scaleQuote,
+ rememberQuote,
+ refreshQuoteBook,
+ refreshSwagger,
+ swaggerHtml,
+ countryDto,
+ languageDto,
+ tryDbRead,
+ putCache,
+ getCached,
+ highlightJson,
+ localVersion,
+ sendJson,
+ sendVersion,
+ readBody,
+ proxy,
attachRequestTimeout,
+ setSwaggerSpec,
+ getSwaggerSpec,
+ setPool,
+ getPool,
+ boot,
+ maybeExitAfterBoot,
+ REQUEST_TIMEOUT_MS,
server,
};
diff --git a/test/offered-routes.test.js b/test/offered-routes.test.js
new file mode 100644
index 0000000..c31d251
--- /dev/null
+++ b/test/offered-routes.test.js
@@ -0,0 +1,114 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+function fail(msg) {
+ console.error('FAIL:', msg);
+ process.exit(1);
+}
+
+const repoRoot = path.resolve(__dirname, '..');
+const catalogPath = path.join(repoRoot, 'offered-routes.json');
+if (!fs.existsSync(catalogPath)) fail('missing offered-routes.json');
+
+const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
+if (!Array.isArray(catalog.routes) || catalog.routes.length === 0) fail('catalog.routes must be a non-empty array');
+
+process.env.BACKEND_URL = process.env.BACKEND_URL || 'http://127.0.0.1:9';
+const { CACHE_PREFIXES, RAM_GET_PATHS, EXACT_GET_PATHS, EXACT_PUT_PATHS, isServedPath } = require('../server.js');
+
+const PUBLIC_REPOS = new Set([
+ 'DFXswiss/services',
+ 'DFXswiss/packages',
+ 'DFXswiss/dfx-wallet',
+ 'RealUnitCH/app',
+]);
+const METHODS = new Set(['GET', 'PUT']);
+
+function namesOf(row) {
+ return [row.path].concat(Array.isArray(row.aliases) ? row.aliases : []);
+}
+
+function rowFor(method, urlPath, match) {
+ return catalog.routes.find((row) => row.method === method && row.path === urlPath && row.match === match);
+}
+
+function catalogCovers(method, urlPath) {
+ return catalog.routes.some((row) => {
+ if (row.method !== method) return false;
+ const names = namesOf(row);
+ if (row.match === 'prefix') {
+ return names.some((n) => urlPath === n || urlPath.startsWith(n + '/'));
+ }
+ return names.includes(urlPath);
+ });
+}
+
+const seen = new Set();
+for (const row of catalog.routes) {
+ if (!METHODS.has(row.method)) fail('bad method: ' + row.method);
+ if (typeof row.path !== 'string' || !row.path.startsWith('/')) fail('bad path: ' + row.path);
+ if (row.match !== 'exact' && row.match !== 'prefix') fail('bad match for ' + row.path);
+ const key = row.method + ' ' + row.path;
+ if (seen.has(key)) fail('duplicate catalog row: ' + key);
+ seen.add(key);
+
+ if (!isServedPath(row.path)) fail('catalog path is not served: ' + row.path);
+ for (const alias of row.aliases ?? []) {
+ if (!isServedPath(alias)) fail('catalog alias is not served: ' + alias);
+ }
+
+ if (!Array.isArray(row.usedIn) || row.usedIn.length === 0) fail('usedIn missing: ' + key);
+ if (!Array.isArray(row.e2e) || row.e2e.length === 0) fail('e2e missing: ' + key);
+
+ for (const ref of row.usedIn.concat(row.e2e)) {
+ if (!ref || typeof ref !== 'object') fail('bad pointer on ' + key);
+ if (ref.unidentified === true) {
+ if (typeof ref.note !== 'string' || !ref.note) fail('unidentified pointer needs note: ' + key);
+ if (ref.repo !== undefined || ref.path !== undefined) fail('unidentified pointer must not set repo or path: ' + key);
+ continue;
+ }
+ if (typeof ref.repo !== 'string' || !PUBLIC_REPOS.has(ref.repo)) {
+ fail('repo must be a listed public consumer: ' + key + ': ' + (ref && ref.repo));
+ }
+ if (typeof ref.path !== 'string' || !ref.path) fail('bad pointer path on ' + key);
+ }
+}
+
+const exactGetNames = new Set();
+for (const row of catalog.routes) {
+ if (row.method === 'GET' && row.match === 'exact' && !RAM_GET_PATHS.includes(row.path)) {
+ for (const n of namesOf(row)) exactGetNames.add(n);
+ }
+}
+for (const p of EXACT_GET_PATHS) {
+ if (!exactGetNames.has(p)) fail('served GET path missing from exact catalog names: ' + p);
+}
+for (const p of EXACT_PUT_PATHS) {
+ const row = rowFor('PUT', p, 'exact');
+ if (!row) fail('PUT quote missing as exact row: ' + p);
+}
+for (const p of RAM_GET_PATHS) {
+ const row = rowFor('GET', p, 'exact');
+ if (!row) fail('RAM GET missing as exact row: ' + p);
+}
+for (const p of CACHE_PREFIXES) {
+ const row = rowFor('GET', p, 'prefix');
+ if (!row) fail('CACHE_PREFIX missing as prefix row: ' + p);
+ if (!catalogCovers('GET', p + '/x')) fail('CACHE_PREFIX subpath missing from catalog: ' + p + '/x');
+}
+
+const expectedKeys = new Set();
+for (const p of CACHE_PREFIXES) expectedKeys.add('GET ' + p);
+for (const p of RAM_GET_PATHS) expectedKeys.add('GET ' + p);
+for (const p of EXACT_PUT_PATHS) expectedKeys.add('PUT ' + p);
+for (const p of ['/', '/version', '/swagger', '/swagger-json']) expectedKeys.add('GET ' + p);
+for (const key of seen) {
+ if (!expectedKeys.has(key)) fail('unexpected catalog row: ' + key);
+}
+
+if (isServedPath('/v1/user')) fail('isServedPath unexpectedly true for /v1/user');
+if (catalogCovers('GET', '/v1/user')) fail('proxied /v1/user must not be in the catalog');
+
+console.log('ok offered-routes.json', catalog.routes.length, 'rows');
diff --git a/test/preload-pg-throw.js b/test/preload-pg-throw.js
new file mode 100644
index 0000000..19d8b8a
--- /dev/null
+++ b/test/preload-pg-throw.js
@@ -0,0 +1,8 @@
+'use strict';
+
+const Module = require('module');
+const orig = Module._load;
+Module._load = function load(request, parent, isMain) {
+ if (request === 'pg') throw new Error('pg missing');
+ return orig.call(this, request, parent, isMain);
+};
diff --git a/test/run-main-coverage.sh b/test/run-main-coverage.sh
new file mode 100644
index 0000000..e808bd7
--- /dev/null
+++ b/test/run-main-coverage.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Collect coverage for `node server.js` as the process entry (require.main).
+set -euo pipefail
+export BACKEND_URL="${BACKEND_URL:-http://127.0.0.1:9}"
+export PORT="${PORT:-0}"
+export BIND="${BIND:-127.0.0.1}"
+export REQUEST_TIMEOUT_MS="${REQUEST_TIMEOUT_MS:-50}"
+export FRONT_API_EXIT_AFTER_BOOT=1
+exec npx c8 --clean=false --reporter=none node server.js
diff --git a/test/server.test.js b/test/server.test.js
new file mode 100644
index 0000000..ef4eee0
--- /dev/null
+++ b/test/server.test.js
@@ -0,0 +1,907 @@
+'use strict';
+
+const http = require('http');
+const net = require('net');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+
+const repoRoot = path.join(__dirname, '..');
+const serverJs = path.join(repoRoot, 'server.js');
+
+function fail(msg) {
+ console.error('FAIL:', msg);
+ process.exit(1);
+}
+
+function childEnv(extra) {
+ return Object.assign({}, process.env, extra || {});
+}
+
+function listen(srv, host) {
+ return new Promise((resolve, reject) => {
+ srv.listen(0, host || '127.0.0.1', () => resolve(srv.address().port));
+ srv.on('error', reject);
+ });
+}
+
+function close(srv) {
+ return new Promise((resolve, reject) => {
+ if (!srv.listening) {
+ resolve();
+ return;
+ }
+ srv.close((err) => (err ? reject(err) : resolve()));
+ });
+}
+
+function request(port, method, urlPath, body, headers) {
+ return new Promise((resolve, reject) => {
+ const payload =
+ body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
+ const req = http.request(
+ {
+ hostname: '127.0.0.1',
+ port,
+ path: urlPath,
+ method,
+ headers: Object.assign(
+ payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
+ headers || {},
+ ),
+ },
+ (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ resolve({
+ status: res.statusCode,
+ body: Buffer.concat(chunks).toString('utf8'),
+ headers: res.headers,
+ });
+ });
+ },
+ );
+ req.on('error', reject);
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+function fakeRes() {
+ return {
+ headersSent: false,
+ status: 0,
+ headers: null,
+ body: null,
+ writeHead(status, headers) {
+ this.status = status;
+ this.headers = headers;
+ this.headersSent = true;
+ },
+ end(body) {
+ this.body = body;
+ },
+ };
+}
+
+function jsonHandler(routes) {
+ return (req, res) => {
+ const p = (req.url || '/').split('?')[0];
+ const hit = routes[p];
+ if (typeof hit === 'function') {
+ hit(req, res);
+ return;
+ }
+ if (hit === undefined) {
+ res.writeHead(404, { 'content-type': 'application/json' });
+ res.end('{}');
+ return;
+ }
+ const body = Buffer.from(typeof hit === 'string' ? hit : JSON.stringify(hit));
+ res.writeHead(200, { 'content-type': 'application/json', 'transfer-encoding': 'chunked' });
+ res.end(body);
+ };
+}
+
+async function main() {
+ const assets = [
+ { id: 1, name: 'BTC', uniqueName: 'BTC', buyable: true, sellable: true },
+ { id: 2, name: 'ETH', buyable: true, sellable: false },
+ ];
+ const fiats = [
+ { id: 10, name: 'CHF' },
+ { id: 11, name: 'EUR' },
+ { id: 12, name: 'USD' },
+ ];
+ const quote = { rate: 2, fees: { rate: 0.01, fixed: 0 } };
+ const ram = { price: 1 };
+ const swagger = {
+ paths: { '/v1/asset': { get: {} }, '/v1/user': { get: {} }, '/version': { get: {} } },
+ };
+
+ const backend = http.createServer(
+ jsonHandler({
+ '/v1/asset': assets,
+ '/v1/fiat': fiats,
+ '/v1/buy/quote': quote,
+ '/v1/sell/quote': quote,
+ '/v1/swap/quote': quote,
+ '/v1/realunit/quote/buyPrice': ram,
+ '/v1/realunit/quote/buyShares': ram,
+ '/v1/realunit/quote/info': ram,
+ '/v1/realunit/quote/price': ram,
+ '/v1/realunit/brokerbot/buyPrice': ram,
+ '/v1/realunit/brokerbot/buyShares': ram,
+ '/v1/realunit/brokerbot/info': ram,
+ '/v1/realunit/brokerbot/price': ram,
+ '/swagger-json': swagger,
+ '/v1/statistic': { ok: 1 },
+ '/v1/setting': { ok: 1 },
+ '/v1/bank': { ok: 1 },
+ '/v1/app': { ok: 1 },
+ '/v1/coin': { ok: 1 },
+ }),
+ );
+ const bPort = await listen(backend);
+ process.env.BACKEND_URL = 'http://127.0.0.1:' + bPort;
+ process.env.PORT = '0';
+ process.env.REQUEST_TIMEOUT_MS = '50';
+ delete process.env.BIND;
+ delete process.env.CACHE_TTL_MS;
+ delete process.env.CACHE_MAX;
+ delete process.env.SQL_HOST;
+ delete process.env.QUOTE_BOOK_REFRESH;
+
+ const s = require(serverJs);
+ const {
+ QUOTE_TTL_MS,
+ CACHE_MAX,
+ CACHE_PREFIXES,
+ RAM_GET_PATHS,
+ quoteBook,
+ cache,
+ pairKey,
+ isQuoteFresh,
+ isServedPath,
+ isCacheable,
+ cacheKey,
+ scaleQuote,
+ rememberQuote,
+ refreshQuoteBook,
+ refreshSwagger,
+ swaggerHtml,
+ countryDto,
+ languageDto,
+ tryDbRead,
+ putCache,
+ getCached,
+ highlightJson,
+ localVersion,
+ sendJson,
+ sendVersion,
+ attachRequestTimeout,
+ setSwaggerSpec,
+ getSwaggerSpec,
+ setPool,
+ getPool,
+ proxy,
+ boot,
+ maybeExitAfterBoot,
+ orFallback,
+ backendPortFor,
+ REQUEST_TIMEOUT_MS,
+ server,
+ } = s;
+
+ if (maybeExitAfterBoot() !== false) fail('maybeExitAfterBoot off');
+ if (REQUEST_TIMEOUT_MS !== 50) fail('REQUEST_TIMEOUT_MS env');
+ if (orFallback('', 'x') !== 'x' || orFallback('a', 'x') !== 'a') fail('orFallback');
+ if (orFallback(undefined, 'x') !== 'x' || orFallback(null, 'x') !== 'x') fail('orFallback nullish');
+ const { URL } = require('url');
+ if (backendPortFor(new URL('http://127.0.0.1:9')) !== 9) fail('backendPort set');
+ if (backendPortFor(new URL('http://127.0.0.1')) !== 80) fail('backendPort 80');
+ if (backendPortFor(new URL('https://example.com')) !== 443) fail('backendPort 443');
+ if (!(QUOTE_TTL_MS > 0) || !(CACHE_MAX > 0)) fail('constants');
+ if (!CACHE_PREFIXES.includes('/v1/asset')) fail('CACHE_PREFIXES');
+ if (!RAM_GET_PATHS.includes('/v1/realunit/quote/price')) fail('RAM_GET_PATHS');
+ if (getPool() !== null) fail('pool default');
+
+ if (!isQuoteFresh({ json: { ok: 1 }, at: Date.now() })) fail('isQuoteFresh fresh');
+ if (isQuoteFresh({ json: { ok: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 })) fail('isQuoteFresh stale');
+ if (isQuoteFresh(undefined) || isQuoteFresh({ json: { ok: 1 } })) fail('isQuoteFresh miss');
+
+ if (pairKey('buy', {}) !== 'buy||||Bank') fail('pairKey empty');
+ if (pairKey('buy', { currency: { name: 'CHF' }, asset: { uniqueName: 'BTC' } }).indexOf('CHF') < 0) {
+ fail('pairKey uniqueName');
+ }
+ if (pairKey('swap', { sourceAsset: { id: 1 }, targetAsset: { name: 'ETH' } }).indexOf('ETH') < 0) {
+ fail('pairKey swap');
+ }
+ if (pairKey('buy', { currency: { id: 1 }, asset: { name: 'BTC' }, paymentMethod: 'Instant' }).indexOf('Instant') < 0) {
+ fail('pairKey instant');
+ }
+ if (pairKey('buy', { currency: { id: 1 }, sourceAsset: { name: 'A' } }).indexOf('A') < 0) fail('pairKey source');
+
+ if (!isServedPath('/version') || !isServedPath('/swagger/') || !isServedPath('/swagger-json/')) fail('isServedPath meta');
+ if (!isServedPath('/swagger-ui') || !isServedPath('/swagger-ui/')) fail('isServedPath ui');
+ if (!isServedPath('/v1/buy/quote') || !isServedPath('/v1/sell/quote') || !isServedPath('/v1/swap/quote')) {
+ fail('isServedPath quotes');
+ }
+ if (!isServedPath('/v1/asset/1') || !isServedPath(undefined)) fail('isServedPath');
+ if (!isServedPath('/v1/realunit/quote/price')) fail('isServedPath ram');
+ if (isServedPath('/v1/user')) fail('isServedPath user');
+
+ if (!isCacheable({ method: 'GET', url: '/v1/asset', headers: {} })) fail('cache GET');
+ if (!isCacheable({ method: 'HEAD', url: '/', headers: {} })) fail('cache HEAD');
+ if (!isCacheable({ method: 'GET', url: '/version', headers: {} })) fail('cache version');
+ if (!isCacheable({ method: 'GET', url: '/swagger', headers: {} })) fail('cache swagger');
+ if (!isCacheable({ method: 'GET', url: '/swagger-json', headers: {} })) fail('cache swagger-json');
+ if (isCacheable({ method: 'PUT', url: '/v1/asset', headers: {} })) fail('cache PUT');
+ if (isCacheable({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('cache auth');
+ if (isCacheable({ method: 'GET', url: '/v1/user', headers: {} })) fail('cache user');
+ if (cacheKey({ method: 'GET', url: '/a' }) !== 'GET /a') fail('cacheKey');
+
+ const scaledAmt = scaleQuote({ json: { rate: 2, fees: { rate: 0.01, fixed: 1 } } }, { amount: 100 });
+ if (scaledAmt.estimatedAmount !== 50 || scaledAmt.feeAmount !== 2) fail('scale amount');
+ const scaledTgt = scaleQuote({ json: { rate: 2, fees: { rate: 0.01 } } }, { targetAmount: 10 });
+ if (scaledTgt.amount !== 20) fail('scale target');
+ if (scaleQuote({ json: { rate: 0 } }, { amount: 1 }) !== null) fail('scale zero');
+ if (scaleQuote({ json: { rate: 2 } }, {}).rate !== 2) fail('scale none');
+ if (scaleQuote({ json: { rate: 2, fees: { rate: 'x' } } }, { amount: 10 }).feeAmount !== undefined) {
+ fail('scale fees type');
+ }
+ const scaledZeroFee = scaleQuote({ json: { rate: 2, fees: { rate: 0, fixed: 0 } } }, { amount: 10 });
+ if (scaledZeroFee.feeAmount !== 0) fail('scale fee fallback');
+ const scaledZeroFeeT = scaleQuote({ json: { rate: 2, fees: { rate: 0, fixed: 0 } } }, { targetAmount: 10 });
+ if (scaledZeroFeeT.feeAmount !== 0) fail('scale fee fallback target');
+
+ const m = new Map();
+ rememberQuote(m, 'buy', { json: { rate: 1 }, at: 1 }, [{ currency: { id: 1 }, asset: { id: 2 } }]);
+ if (m.size !== 1) fail('rememberQuote');
+
+ if (swaggerHtml().indexOf('swagger-ui') < 0) fail('swaggerHtml');
+ if (localVersion().commit !== 'front-api') fail('localVersion');
+ const hi = highlightJson({ a: 'b&<>', k: 'v' });
+ if (hi.indexOf('&') < 0 || hi.indexOf('<') < 0 || hi.indexOf('class="k"') < 0) fail('highlightJson');
+
+ const dto = countryDto({
+ id: 1,
+ symbol: 'CH',
+ name: 'Switzerland',
+ foreignName: 'Schweiz',
+ ipEnable: 1,
+ fatfEnable: 1,
+ dfxEnable: 1,
+ dfxOrganizationEnable: 0,
+ nationalityStepEnable: 1,
+ bankEnable: 1,
+ checkoutEnable: 0,
+ cryptoEnable: 1,
+ });
+ if (!dto.bankAllowed || dto.cardAllowed) fail('countryDto');
+ const dtoCard = countryDto({
+ id: 2,
+ symbol: 'DE',
+ name: 'Germany',
+ foreignName: 'x',
+ ipEnable: 0,
+ fatfEnable: 1,
+ dfxEnable: 0,
+ dfxOrganizationEnable: 1,
+ nationalityStepEnable: 0,
+ bankEnable: 1,
+ checkoutEnable: 1,
+ cryptoEnable: 0,
+ });
+ if (!dtoCard.cardAllowed || dtoCard.bankAllowed) fail('countryDto card');
+ if (!languageDto({ id: 1, name: 'English', symbol: 'EN', foreignName: 'x', enable: 1 }).enable) fail('languageDto');
+
+ cache.clear();
+ putCache('a', 200, { h: '1' }, Buffer.from('one'));
+ if (!getCached('a')) fail('getCached');
+ for (let i = 0; i < CACHE_MAX + 2; i++) putCache('k' + i, 200, {}, Buffer.from(String(i)));
+ if (cache.size > CACHE_MAX) fail('eviction');
+
+ const resJson = fakeRes();
+ sendJson(resJson, 200, { ok: 1 }, 'local');
+ if (resJson.status !== 200) fail('sendJson object');
+ const resBuf = fakeRes();
+ sendJson(resBuf, 200, Buffer.from('{"x":1}'), 'db');
+ if (String(resBuf.body).indexOf('"x"') < 0) fail('sendJson buffer');
+ const resRaw = fakeRes();
+ sendJson(resRaw, 200, Buffer.from('not-json'), 'db');
+ if (String(resRaw.body) !== 'not-json') fail('sendJson raw');
+
+ const resVer = fakeRes();
+ sendVersion({ headers: {} }, resVer, { commit: 'x' }, 'local');
+ if (resVer.status !== 200) fail('sendVersion json');
+ const resHtml = fakeRes();
+ sendVersion({ headers: { accept: 'text/html' } }, resHtml, { commit: 'x' }, 'local');
+ if (String(resHtml.headers['content-type']).indexOf('text/html') < 0) fail('sendVersion html');
+
+ if ((await tryDbRead('/v1/country')) !== null) fail('tryDbRead no pool');
+ setPool({ query: async () => ({ rows: [{ id: 1, name: 'X', symbol: 'X', foreignName: 'X', enable: 1 }] }) });
+ if (!(await tryDbRead('/v1/language'))) fail('tryDbRead language');
+ if ((await tryDbRead('/nope')) !== null) fail('tryDbRead unknown');
+ setPool({ query: async () => null });
+ if ((await tryDbRead('/v1/country')) !== null) fail('tryDbRead null result');
+ setPool(null);
+
+ await refreshQuoteBook();
+ if (quoteBook.buy.size === 0 || quoteBook.sell.size === 0 || quoteBook.realunit.size === 0) {
+ fail('refreshQuoteBook empty');
+ }
+ await refreshSwagger();
+ if (!getSwaggerSpec() || !getSwaggerSpec().paths['/v1/asset'] || getSwaggerSpec().paths['/v1/user']) {
+ fail('refreshSwagger allowlist');
+ }
+
+ const port = await listen(server);
+ try {
+ quoteBook.realunit.clear();
+ let got = await request(port, 'GET', '/v1/realunit/quote/price');
+ if (got.status !== 503) fail('ram_miss');
+ quoteBook.realunit.set('/v1/realunit/quote/price', { json: { price: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 });
+ got = await request(port, 'GET', '/v1/realunit/quote/price');
+ if (got.status !== 503) fail('ram_stale');
+ quoteBook.realunit.set('/v1/realunit/quote/price', { json: { price: 42 }, at: Date.now() });
+ got = await request(port, 'GET', '/v1/realunit/quote/price');
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'ram') fail('ram_fresh');
+ quoteBook.realunit.set('/v1/realunit/brokerbot/price', { json: { price: 3 }, at: Date.now() });
+ got = await request(port, 'GET', '/v1/realunit/brokerbot/price');
+ if (got.status !== 200) fail('ram brokerbot');
+
+ const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
+ quoteBook.buy.clear();
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 503) fail('buy miss');
+ quoteBook.buy.set(pairKey('buy', buyBody), { json: { rate: 2 }, at: Date.now() });
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 200) fail('buy fresh');
+ quoteBook.buy.set(pairKey('buy', buyBody), { json: { rate: 0 }, at: Date.now() });
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 503) fail('buy zero');
+ got = await request(port, 'PUT', '/v1/buy/quote', Buffer.from('not-json'));
+ if (got.status !== 400) fail('buy invalid json');
+ got = await request(port, 'PUT', '/v1/buy/quote', Buffer.alloc(0));
+ if (got.status !== 503 && got.status !== 200) fail('buy empty body');
+
+ quoteBook.sell.set(pairKey('sell', buyBody), { json: { rate: 2 }, at: Date.now() });
+ got = await request(port, 'PUT', '/v1/sell/quote', buyBody);
+ if (got.status !== 200) fail('sell');
+ const swapBody = { sourceAsset: { id: 1 }, targetAsset: { id: 2 }, amount: 0.01 };
+ quoteBook.swap.set(pairKey('swap', swapBody), { json: { rate: 2 }, at: Date.now() });
+ got = await request(port, 'PUT', '/v1/swap/quote', swapBody);
+ if (got.status !== 200) fail('swap');
+
+ setSwaggerSpec(null);
+ got = await request(port, 'GET', '/swagger-json');
+ if (got.status !== 503) fail('swagger empty json');
+ got = await request(port, 'GET', '/swagger');
+ if (got.status !== 503) fail('swagger empty html');
+ got = await request(port, 'GET', '/swagger-ui/');
+ if (got.status !== 503) fail('swagger-ui empty');
+ setSwaggerSpec({ paths: { '/v1/asset': {} }, info: { title: 'x' } });
+ got = await request(port, 'GET', '/swagger-json/');
+ if (got.status !== 200) fail('swagger json');
+ got = await request(port, 'GET', '/swagger/');
+ if (got.status !== 200) fail('swagger html');
+ got = await request(port, 'GET', '/swagger-ui');
+ if (got.status !== 200) fail('swagger-ui');
+
+ got = await request(port, 'GET', '/version');
+ if (got.status !== 200 || got.body.indexOf('front-api') < 0) fail('version json');
+ got = await request(port, 'GET', '/version', undefined, { accept: 'text/html' });
+ if (String(got.headers['content-type']).indexOf('text/html') < 0) fail('version html');
+
+ cache.clear();
+ got = await request(port, 'GET', '/v1/statistic');
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'miss') fail('proxy miss');
+ got = await request(port, 'GET', '/v1/statistic');
+ if (got.headers['x-front-api'] !== 'hit') fail('proxy hit');
+
+ setPool({
+ query: async () => ({
+ rows: [
+ {
+ id: 1,
+ symbol: 'CH',
+ name: 'Switzerland',
+ foreignName: 'X',
+ ipEnable: 1,
+ fatfEnable: 1,
+ dfxEnable: 1,
+ dfxOrganizationEnable: 0,
+ nationalityStepEnable: 1,
+ bankEnable: 1,
+ checkoutEnable: 0,
+ cryptoEnable: 1,
+ },
+ ],
+ }),
+ });
+ got = await request(port, 'GET', '/v1/country');
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'db') fail('db country');
+ setPool({ query: async () => null });
+ got = await request(port, 'GET', '/v1/language');
+ if (!got.status) fail('db null');
+ putCache('GET /v1/language', 200, { 'content-type': 'application/json' }, Buffer.from('{"s":1}'));
+ cache.get('GET /v1/language').exp = Date.now() - 1;
+ setPool({
+ query: async () => {
+ throw new Error('db down');
+ },
+ });
+ got = await request(port, 'GET', '/v1/language');
+ if (got.headers['x-front-api'] !== 'stale') fail('db catch stale');
+ cache.delete('GET /v1/language');
+ cache.clear();
+ got = await request(port, 'GET', '/v1/language');
+ if (!got.status) fail('db catch proxy');
+ setPool(null);
+ got = await request(port, 'GET', '/v1/country', undefined, { authorization: 'Bearer x' });
+ if (!got.status) fail('db skip auth');
+
+ await new Promise((resolve, reject) => {
+ const held = [];
+ const hanging = net.createServer((sock) => held.push(sock));
+ hanging.listen(0, '127.0.0.1', () => {
+ const hPort = hanging.address().port;
+ const req = http.request({ hostname: '127.0.0.1', port: hPort, path: '/', method: 'GET' });
+ let timedOut = false;
+ attachRequestTimeout(req, 50, () => {
+ timedOut = true;
+ req.destroy();
+ });
+ req.on('error', () => {
+ for (const sock of held) sock.destroy();
+ hanging.close(() => (timedOut ? resolve() : reject(new Error('timeout'))));
+ });
+ req.end();
+ });
+ hanging.on('error', reject);
+ });
+
+ await new Promise((resolve) => {
+ const sock = net.connect(port, '127.0.0.1', () => {
+ sock.write(
+ 'GET /socket HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-A: 1\r\nX-A: 2\r\n\r\n',
+ );
+ });
+ sock.on('error', () => resolve());
+ sock.on('data', () => {
+ sock.destroy();
+ resolve();
+ });
+ setTimeout(() => {
+ sock.destroy();
+ resolve();
+ }, 300);
+ });
+
+ const liveUp = net.connect({ port: bPort, host: '127.0.0.1' });
+ liveUp.on('error', () => {});
+ server.emit(
+ 'upgrade',
+ { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x', skip: undefined, arr: ['a', 'b'] } },
+ liveUp,
+ Buffer.from('hi'),
+ );
+ await new Promise((r) => setTimeout(r, 50));
+ liveUp.destroy();
+
+ const sent = fakeRes();
+ sent.headersSent = true;
+ const fakeReq = new http.IncomingMessage(new net.Socket());
+ fakeReq.method = 'GET';
+ fakeReq.url = '/nope';
+ fakeReq.headers = {};
+ proxy(fakeReq, sent, null);
+ const noUrl = fakeRes();
+ const noUrlReq = new http.IncomingMessage(new net.Socket());
+ noUrlReq.method = 'GET';
+ noUrlReq.url = undefined;
+ noUrlReq.headers = {};
+ server.emit('request', noUrlReq, noUrl);
+
+ await close(backend);
+ cache.clear();
+ putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"stale":true}'));
+ cache.get('GET /v1/statistic').exp = Date.now() - 1;
+ got = await request(port, 'GET', '/v1/statistic');
+ if (got.headers['x-front-api'] !== 'stale') fail('proxy stale after backend down');
+ cache.clear();
+ got = await request(port, 'GET', '/v1/statistic');
+ if (got.status !== 503) fail('proxy 503 after backend down');
+
+ const heldHang = [];
+ const hang = net.createServer((c) => heldHang.push(c));
+ await new Promise((resolve, reject) => {
+ hang.listen(bPort, '127.0.0.1', resolve);
+ hang.on('error', reject);
+ });
+ await refreshQuoteBook();
+ got = await request(port, 'GET', '/v1/coin');
+ if (got.status !== 503 && got.headers['x-front-api'] !== 'stale') fail('proxy hang timeout');
+ for (const c of heldHang) c.destroy();
+ await close(hang);
+
+ await new Promise((resolve) => {
+ const sock = net.connect(bPort, '127.0.0.1');
+ sock.on('error', () => resolve());
+ sock.on('connect', () => {
+ sock.destroy();
+ resolve();
+ });
+ setTimeout(resolve, 100);
+ });
+ } finally {
+ await close(server);
+ }
+
+ const emptyBook = http.createServer(jsonHandler({ '/v1/asset': [], '/v1/fiat': [{ id: 10, name: 'CHF' }] }));
+ const emptyPort = await listen(emptyBook);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + emptyPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshQuoteBook().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 5000);
+ });
+ await close(emptyBook);
+
+ const noChf = http.createServer(jsonHandler({ '/v1/asset': assets, '/v1/fiat': [{ id: 11, name: 'EUR' }] }));
+ const noChfPort = await listen(noChf);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + noChfPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshQuoteBook().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 5000);
+ });
+ await close(noChf);
+
+ const notArr = http.createServer(jsonHandler({ '/v1/asset': { no: 'array' }, '/v1/fiat': fiats }));
+ const naPort = await listen(notArr);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + naPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshQuoteBook().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 5000);
+ });
+ await close(notArr);
+
+ const badJson = http.createServer((req, res) => {
+ res.end('not-json');
+ });
+ const bjPort = await listen(badJson);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + bjPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshSwagger().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 5000);
+ });
+ await close(badJson);
+
+ const noPaths = http.createServer(jsonHandler({ '/swagger-json': { info: {} } }));
+ const npPort = await listen(noPaths);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + npPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshSwagger().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 5000);
+ });
+ await close(noPaths);
+
+ const quoteErr = http.createServer((req, res) => {
+ const p = (req.url || '/').split('?')[0];
+ if (p === '/v1/asset') {
+ res.end(JSON.stringify(assets));
+ return;
+ }
+ if (p === '/v1/fiat') {
+ res.end(JSON.stringify(fiats));
+ return;
+ }
+ req.destroy();
+ });
+ const qePort = await listen(quoteErr);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + qePort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshQuoteBook().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 8000);
+ });
+ await close(quoteErr);
+
+ const noUnique = http.createServer(
+ jsonHandler({
+ '/v1/asset': [{ id: 1, name: 'A', buyable: true, sellable: true }],
+ '/v1/fiat': [{ id: 10, name: 'CHF' }],
+ '/v1/buy/quote': quote,
+ '/v1/sell/quote': quote,
+ '/v1/swap/quote': { rate: 2 },
+ '/v1/realunit/quote/price': ram,
+ '/v1/realunit/quote/buyPrice': ram,
+ '/v1/realunit/quote/buyShares': ram,
+ '/v1/realunit/quote/info': ram,
+ '/v1/realunit/brokerbot/buyPrice': ram,
+ '/v1/realunit/brokerbot/buyShares': ram,
+ '/v1/realunit/brokerbot/info': ram,
+ '/v1/realunit/brokerbot/price': ram,
+ }),
+ );
+ const nuPort = await listen(noUnique);
+ await new Promise((resolve, reject) => {
+ const child = spawn(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + nuPort)};
+const s=require(${JSON.stringify(serverJs)});
+s.refreshQuoteBook().then(()=>process.exit(0));`,
+ ],
+ { env: childEnv() },
+ );
+ child.on('exit', () => resolve());
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 8000);
+ });
+ await close(noUnique);
+
+ await close(backend);
+
+ const missing = spawnSync(process.execPath, [serverJs], {
+ env: childEnv({ BACKEND_URL: '' }),
+ encoding: 'utf8',
+ timeout: 5000,
+ });
+ if (missing.status === 0) fail('BACKEND_URL required');
+ const missing2 = spawnSync(process.execPath, [serverJs], {
+ env: childEnv({ BACKEND_URL: undefined }),
+ encoding: 'utf8',
+ timeout: 5000,
+ });
+ void missing2;
+
+ const sqlMiss = spawnSync(process.execPath, [serverJs], {
+ env: childEnv({ BACKEND_URL: 'http://127.0.0.1:9', SQL_HOST: '127.0.0.1', SQL_PORT: '', SQL_DB: '', SQL_USERNAME: '' }),
+ encoding: 'utf8',
+ timeout: 5000,
+ });
+ if (sqlMiss.status === 0) fail('SQL incomplete');
+
+ const sqlSsl = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+process.env.SQL_HOST='127.0.0.1';
+process.env.SQL_PORT='5432';
+process.env.SQL_DB='db';
+process.env.SQL_USERNAME='u';
+process.env.SQL_PASSWORD='';
+process.env.SQL_SSL='true';
+const s=require(${JSON.stringify(serverJs)});
+if(!s.getPool()) process.exit(2);
+s.getPool().emit('error', new Error('boom'));
+process.exit(0);`,
+ ],
+ { env: childEnv(), encoding: 'utf8', timeout: 8000 },
+ );
+ if (sqlSsl.status !== 0) fail('sql ssl ' + (sqlSsl.stderr || sqlSsl.status));
+
+ const sqlPlain = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+process.env.SQL_HOST='127.0.0.1';
+process.env.SQL_PORT='5432';
+process.env.SQL_DB='db';
+process.env.SQL_USERNAME='u';
+process.env.SQL_PASSWORD='p';
+delete process.env.SQL_SSL;
+const s=require(${JSON.stringify(serverJs)});
+if(!s.getPool()) process.exit(2);
+process.exit(0);`,
+ ],
+ { env: childEnv(), encoding: 'utf8', timeout: 8000 },
+ );
+ if (sqlPlain.status !== 0) fail('sql plain ' + (sqlPlain.stderr || sqlPlain.status));
+
+ const pgThrow = spawnSync(
+ process.execPath,
+ ['-r', path.join(repoRoot, 'test', 'preload-pg-throw.js'), serverJs],
+ {
+ env: childEnv({
+ BACKEND_URL: 'http://127.0.0.1:9',
+ SQL_HOST: '127.0.0.1',
+ SQL_PORT: '5432',
+ SQL_DB: 'db',
+ SQL_USERNAME: 'u',
+ SQL_PASSWORD: 'p',
+ }),
+ encoding: 'utf8',
+ timeout: 5000,
+ },
+ );
+ if (pgThrow.status === 0) fail('pg throw');
+
+ await new Promise((resolve, reject) => {
+ server.once('listening', resolve);
+ server.once('error', reject);
+ process.env.QUOTE_BOOK_REFRESH = '';
+ boot();
+ });
+ await close(server);
+ await new Promise((resolve, reject) => {
+ server.once('listening', resolve);
+ server.once('error', reject);
+ process.env.QUOTE_BOOK_REFRESH = '1';
+ setPool({ query: async () => ({ rows: [] }) });
+ boot();
+ });
+ await close(server);
+
+ const listenOff = await new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [serverJs], {
+ env: childEnv({ BACKEND_URL: 'http://127.0.0.1:9', PORT: '0', BIND: '127.0.0.1', QUOTE_BOOK_REFRESH: '' }),
+ });
+ let out = '';
+ const done = () => {
+ child.kill('SIGTERM');
+ resolve(out);
+ };
+ child.stdout.on('data', (d) => {
+ out += d;
+ if (out.indexOf('quote book refresh disabled') >= 0) done();
+ });
+ child.stderr.on('data', (d) => {
+ out += d;
+ });
+ child.on('error', reject);
+ setTimeout(() => {
+ child.kill('SIGKILL');
+ resolve(out);
+ }, 4000);
+ });
+ if (listenOff.indexOf('quote book refresh disabled') < 0) fail('listen off: ' + listenOff);
+
+ const listenOn = await new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [serverJs], {
+ env: childEnv({
+ BACKEND_URL: 'http://127.0.0.1:9',
+ PORT: '0',
+ BIND: '127.0.0.1',
+ QUOTE_BOOK_REFRESH: '1',
+ SQL_HOST: '127.0.0.1',
+ SQL_PORT: '5432',
+ SQL_DB: 'db',
+ SQL_USERNAME: 'u',
+ SQL_PASSWORD: 'p',
+ }),
+ });
+ let out = '';
+ child.stdout.on('data', (d) => {
+ out += d;
+ if (out.indexOf('listening') >= 0) child.kill('SIGTERM');
+ });
+ child.stderr.on('data', (d) => {
+ out += d;
+ });
+ child.on('exit', () => resolve(out));
+ child.on('error', reject);
+ setTimeout(() => child.kill('SIGKILL'), 4000);
+ });
+ if (listenOn.indexOf('db-read on') < 0 && listenOn.indexOf('listening') < 0) fail('listen on: ' + listenOn);
+
+ const httpsChild = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='https://127.0.0.1';
+const http=require('http');
+const s=require(${JSON.stringify(serverJs)});
+s.server.listen(0,'127.0.0.1',()=>{
+ http.get({hostname:'127.0.0.1',port:s.server.address().port,path:'/v1/setting'},(res)=>{
+ res.resume();
+ res.on('end',()=>s.server.close(()=>process.exit(0)));
+ }).on('error',()=>s.server.close(()=>process.exit(0)));
+});`,
+ ],
+ { env: childEnv(), encoding: 'utf8', timeout: 8000 },
+ );
+ void httpsChild;
+
+ const http80 = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1';
+const http=require('http');
+const s=require(${JSON.stringify(serverJs)});
+s.server.listen(0,'127.0.0.1',()=>{
+ http.get({hostname:'127.0.0.1',port:s.server.address().port,path:'/v1/bank'},(res)=>{
+ res.resume();
+ res.on('end',()=>s.server.close(()=>process.exit(0)));
+ }).on('error',()=>s.server.close(()=>process.exit(0)));
+});`,
+ ],
+ { env: childEnv(), encoding: 'utf8', timeout: 8000 },
+ );
+ void http80;
+ const bootErr = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+process.env.FRONT_API_EXIT_AFTER_BOOT='1';
+const s=require(${JSON.stringify(serverJs)});
+if(!s.maybeExitAfterBoot()) process.exit(2);
+s.server.emit('error', new Error('boot fail'));
+setTimeout(()=>process.exit(3), 1000);`,
+ ],
+ { env: childEnv({ FRONT_API_EXIT_AFTER_BOOT: '1' }), encoding: 'utf8', timeout: 3000 },
+ );
+ if (bootErr.status !== 1) fail('maybeExitAfterBoot error exit: ' + bootErr.status);
+
+ console.log('ok front-api server.js');
+}
+
+main().catch((err) => {
+ console.error('FAIL:', err && err.stack ? err.stack : err);
+ process.exit(1);
+});
diff --git a/test/test-offered-routes.sh b/test/test-offered-routes.sh
new file mode 100755
index 0000000..eddd86d
--- /dev/null
+++ b/test/test-offered-routes.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Pin: every path this process answers itself is listed in offered-routes.json
+# with a public usedIn pointer and a frontend E2E pointer. This job does not
+# run foreign E2E suites.
+#
+# Arms:
+# offered-routes.json exists catalog_file
+# CI job test runs this script ci_wired
+# npm test runs this script npm_test
+# CONTRIBUTING names the catalog and E2E review gate contributing
+# REVIEW.md has the offered-route E2E item review_item
+# node test/offered-routes.test.js (1:1 + schema) catalog_1to1
+set -euo pipefail
+
+repo_root=$(cd "$(dirname "$0")/.." && pwd)
+catalog="$repo_root/offered-routes.json"
+wf="$repo_root/.github/workflows/test.yml"
+pkg="$repo_root/package.json"
+contrib="$repo_root/CONTRIBUTING.md"
+review="$repo_root/REVIEW.md"
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+[ -f "$catalog" ] || fail "catalog_file: missing offered-routes.json"
+grep -q 'test/test-offered-routes.sh' "$wf" || fail "ci_wired: test.yml must run test-offered-routes.sh"
+grep -q 'test/test-offered-routes.sh' "$pkg" || fail "npm_test: package.json test must run test-offered-routes.sh"
+grep -q 'offered-routes.json' "$contrib" || fail "contributing: offered-routes.json missing"
+grep -q 'frontend E2E' "$contrib" || fail "contributing: frontend E2E missing"
+grep -q 'offered-routes.json' "$review" || fail "review_item: offered-routes.json missing"
+grep -q 'frontend E2E' "$review" || fail "review_item: frontend E2E missing"
+
+node "$repo_root/test/offered-routes.test.js" || fail "catalog_1to1"
+
+echo "ok offered-routes.json"
diff --git a/test/test-server.sh b/test/test-server.sh
index d6ba3f2..07f1155 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Pin test for server.js (RAM TTL + proxy timeout)
+# Pin test + 100% coverage gate for production JS (c8).
#
# Arms:
# RAM miss → 503 ram_miss
@@ -8,10 +8,15 @@
# swagger snapshot empty → 503 local body swagger_empty
# attachRequestTimeout → callback + destroy proxy_timeout
# poller default off (QUOTE_BOOK_REFRESH!==1) poller_off
+# c8 100% lines/functions/branches/statements coverage_100
+# c8 --all includes every new production .js file coverage_all
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/.." && pwd)
server_js="$repo_root/server.js"
+test_js="$repo_root/test/server.test.js"
+wf="$repo_root/.github/workflows/test.yml"
+pkg="$repo_root/package.json"
fail() {
echo "FAIL: $*" >&2
@@ -19,6 +24,7 @@ fail() {
}
[ -f "$server_js" ] || fail "missing: $server_js"
+[ -f "$test_js" ] || fail "missing: $test_js"
grep -q "QUOTE_BOOK_REFRESH === '1'" "$server_js" || fail "poller_off: gate missing"
grep -q 'refreshQuoteBook();' "$server_js" || fail "poller_off: refresh helper missing"
grep -q 'function isServedPath' "$server_js" || fail "isServedPath missing"
@@ -27,152 +33,39 @@ if grep -q 'low.includes' "$server_js"; then
fail "swagger snapshot must not denylist unserved routes"
fi
-tmp=$(mktemp -d)
-trap 'rm -rf "$tmp"' EXIT
-
-cat >"$tmp/run-tests.js" <<'JS'
-'use strict';
-
-const http = require('http');
-const net = require('net');
-
-const serverJs = process.argv[2];
-process.env.BACKEND_URL = 'http://127.0.0.1:9';
-delete process.env.SQL_HOST;
-
-const {
- QUOTE_TTL_MS,
- quoteBook,
- pairKey,
- isQuoteFresh,
- attachRequestTimeout,
- server,
-} = require(serverJs);
-
-function fail(msg) {
- console.error('FAIL:', msg);
- process.exit(1);
-}
-
-if (!(QUOTE_TTL_MS > 0)) fail('QUOTE_TTL_MS unset');
-if (!isQuoteFresh({ json: { ok: 1 }, at: Date.now() })) fail('isQuoteFresh: fresh should pass');
-if (isQuoteFresh({ json: { ok: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 })) fail('isQuoteFresh: stale should fail');
-if (isQuoteFresh(undefined)) fail('isQuoteFresh: miss should fail');
-
-function request(port, method, urlPath, body) {
- return new Promise((resolve, reject) => {
- const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
- const req = http.request(
- {
- hostname: '127.0.0.1',
- port,
- path: urlPath,
- method,
- headers: payload
- ? { 'content-type': 'application/json', 'content-length': payload.length }
- : {},
- },
- (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers });
- });
- },
- );
- req.on('error', reject);
- if (payload) req.write(payload);
- req.end();
- });
-}
-
-async function main() {
- await new Promise((resolve, reject) => {
- server.listen(0, '127.0.0.1', resolve);
- server.on('error', reject);
- });
- const port = server.address().port;
- const ruPath = '/v1/realunit/quote/price';
-
- quoteBook.realunit.clear();
- let got = await request(port, 'GET', ruPath);
- if (got.status !== 503) fail(`ram_miss: expected 503 got ${got.status}`);
- if (!got.body.includes('quote unavailable')) fail('ram_miss: body');
-
- quoteBook.realunit.set(ruPath, { json: { price: 1 }, at: Date.now() - QUOTE_TTL_MS - 1000 });
- got = await request(port, 'GET', ruPath);
- if (got.status !== 503) fail(`ram_stale: expected 503 got ${got.status}`);
-
- quoteBook.realunit.set(ruPath, { json: { price: 42 }, at: Date.now() });
- got = await request(port, 'GET', ruPath);
- if (got.status !== 200) fail(`ram_fresh: expected 200 got ${got.status}`);
- if (!got.body.includes('"price": 42') && !got.body.includes('"price":42')) fail('ram_fresh: body');
- if (got.headers['x-front-api'] !== 'ram') fail('ram_fresh: x-front-api');
-
- const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
- const buyKey = pairKey('buy', buyBody);
- quoteBook.buy.clear();
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 503) fail(`ram_miss buy: expected 503 got ${got.status}`);
-
- quoteBook.buy.set(buyKey, {
- json: { rate: 2, amount: 100, estimatedAmount: 50 },
- at: Date.now() - QUOTE_TTL_MS - 1,
- });
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 503) fail(`ram_stale buy: expected 503 got ${got.status}`);
-
- quoteBook.buy.set(buyKey, {
- json: { rate: 2, amount: 100, estimatedAmount: 50 },
- at: Date.now(),
- });
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 200) fail(`ram_fresh buy: expected 200 got ${got.status}`);
-
- got = await request(port, 'GET', '/swagger-json');
- if (got.status !== 503) fail(`swagger_empty json: expected 503 got ${got.status}`);
- if (!got.body.includes('swagger snapshot empty')) fail('swagger_empty json: body');
- if (got.headers['x-front-api'] !== 'local') fail('swagger_empty json: x-front-api');
- got = await request(port, 'GET', '/swagger');
- if (got.status !== 503) fail(`swagger_empty html: expected 503 got ${got.status}`);
- if (!got.body.includes('swagger snapshot empty')) fail('swagger_empty html: body');
+c8rc="$repo_root/.c8rc.json"
+[ -f "$c8rc" ] || fail "coverage_100: missing .c8rc.json"
+grep -q '"lines": 100' "$c8rc" || fail "coverage_100: lines 100 missing from .c8rc.json"
+grep -q '"functions": 100' "$c8rc" || fail "coverage_100: functions 100 missing from .c8rc.json"
+grep -q '"branches": 100' "$c8rc" || fail "coverage_100: branches 100 missing from .c8rc.json"
+grep -q '"statements": 100' "$c8rc" || fail "coverage_100: statements 100 missing from .c8rc.json"
+grep -q '"all": true' "$c8rc" || fail "coverage_all: all missing from .c8rc.json"
+grep -Fq '"**/*.js"' "$c8rc" || fail "coverage_all: include **/*.js missing from .c8rc.json"
+grep -Fq '"test/**"' "$c8rc" || fail "coverage_all: test exclude missing from .c8rc.json"
+grep -Fq '"coverage/**"' "$c8rc" || fail "coverage_all: coverage exclude missing from .c8rc.json"
+grep -Fq '"node_modules/**"' "$c8rc" || fail "coverage_all: node_modules exclude missing from .c8rc.json"
+if grep -q 'server.js' "$c8rc"; then
+ fail "coverage_all: production server.js must not be excluded"
+fi
+grep -q '"c8"' "$pkg" || fail "coverage_100: c8 missing from package.json"
+grep -q 'npm ci' "$wf" || fail "coverage_100: CI must npm ci"
+grep -q 'package-lock.json' "$repo_root/Dockerfile" || fail "coverage_100: image must use lockfile"
+grep -q 'npm ci --omit=dev' "$repo_root/Dockerfile" || fail "coverage_100: image must npm ci omit dev"
+grep -q 'require.main === module' "$server_js" || fail "boot only when main"
+grep -Fq 'orFallback(process.env.REQUEST_TIMEOUT_MS, 20000)' "$server_js" || fail "REQUEST_TIMEOUT_MS default"
+grep -q 'FRONT_API_EXIT_AFTER_BOOT=1' "$repo_root/test/run-main-coverage.sh" || fail "coverage_100: require.main collection missing"
+grep -q 'coverage:report' "$pkg" || fail "coverage_100: coverage:report script missing"
+grep -q -- '--check-coverage' "$pkg" || fail "coverage_100: check-coverage missing from package.json"
+
+cd "$repo_root"
+if [ ! -d node_modules/c8 ]; then
+ npm ci
+fi
- await new Promise((resolve, reject) => {
- const held = [];
- const hanging = net.createServer((s) => {
- held.push(s);
- });
- hanging.listen(0, '127.0.0.1', () => {
- const hPort = hanging.address().port;
- const req = http.request({ hostname: '127.0.0.1', port: hPort, path: '/', method: 'GET' });
- let timedOut = false;
- attachRequestTimeout(req, 50, () => {
- timedOut = true;
- req.destroy();
- });
- req.on('error', () => {
- for (const s of held) s.destroy();
- hanging.close(() => {
- if (!timedOut) reject(new Error('proxy_timeout: destroy without timeout callback'));
- else resolve();
- });
- });
- req.end();
- });
- hanging.on('error', reject);
- });
+npx c8 --reporter=text --reporter=text-summary node test/server.test.js || fail "server tests failed"
- await new Promise((resolve, reject) => {
- server.close((err) => (err ? reject(err) : resolve()));
- });
- console.log('ok front-api server.js');
-}
+bash "$repo_root/test/run-main-coverage.sh" || fail "require.main coverage run failed"
-main().catch((err) => {
- console.error('FAIL:', err && err.message ? err.message : err);
- process.exit(1);
-});
-JS
+npm run coverage:report || fail "coverage 100% gate failed"
-node "$tmp/run-tests.js" "$server_js" || fail "node helper failed"
echo "ok front-api server.js"
From 4c3aa6c1ff2790e2395f9dd8f79e85326af5a248 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 14:40:19 +0200
Subject: [PATCH 04/15] 01a02b56 - Remove in-memory quotes and stale cache
fallback (#3)
* 01a02b56 - Remove in-memory quotes and stale cache fallback
Buy/sell/swap and RealUnit quotes go through the backend proxy.
A down backend always returns 503; expired cache is never served.
* 01a02b56 - Tighten quote-proxy tests and document cache misses
Quote routes are asserted against a mock backend. README states
that only expired or missing cache entries become 503.
* 01a02b56 - Align coverage suite after dropping RAM quotes
Remove quote and RealUnit rows from the offered-routes catalog.
Pin quote proxy bodies and expired-cache 503 in the c8 suite.
---
CONTRIBUTING.md | 7 +-
README.md | 15 +-
offered-routes.json | 217 ++--------------------------
server.js | 274 ++----------------------------------
test/offered-routes.test.js | 16 +--
test/server.test.js | 237 +++----------------------------
test/test-server.sh | 16 ++-
7 files changed, 74 insertions(+), 708 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dc8024d..4a31ffe 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -96,7 +96,7 @@ details body. The `DE:` block is the German summary only.
When applicable, every pull request must include:
1. **Environment / image / workflow updates** when boot or the image is
- affected (`BACKEND_URL`, `SQL_*`, `QUOTE_BOOK_REFRESH`, `CACHE_*`,
+ affected (`BACKEND_URL`, `SQL_*`, `CACHE_*`,
`REQUEST_TIMEOUT_MS`, `FRONT_API_EXIT_AFTER_BOOT`, `PORT`, `BIND`,
Dockerfile, `.github/workflows`).
2. **A pin** in `test/test-server.sh` (`server.js` behaviour),
@@ -104,7 +104,7 @@ When applicable, every pull request must include:
`test/test-auto-release-pr.sh` (the automatic release-PR body) for every
behaviour the pull request changes.
3. **Swagger allowlist** update when the set of paths this process answers
- itself changes (`isServedPath`, `CACHE_PREFIXES`, RAM quote paths).
+ itself changes (`isServedPath`, `CACHE_PREFIXES`).
4. **`offered-routes.json`** update for every path this process answers
itself: a `usedIn` pointer (public consumer repo + file, or
`unidentified: true` with a note) and an `e2e` pointer
@@ -144,7 +144,8 @@ Missing any applicable item = changes requested.
- The swagger snapshot is an **allowlist** of paths this process serves, not a
denylist.
- Authenticated requests are never answered from the GET cache.
-- The quote poller is **off by default** (`QUOTE_BOOK_REFRESH=1` to enable).
+- Quotes are reverse-proxied to `BACKEND_URL`. This process does not keep a quote book.
+- A down backend always returns 503. Never serve an expired cache body.
- Do not expose internals in responses (SQL credentials, backend hosts, or
other secrets).
diff --git a/README.md b/README.md
index c16fa0b..cfc5c03 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# front-api
-Public HTTP layer in front of the DFX backend. This process answers a fixed set of routes itself (`/version`, a filtered swagger snapshot, short-TTL GET cache, optional Postgres reads for country/language, optional in-memory quotes). Every other request is forwarded to `BACKEND_URL` without this repository listing those routes.
+Public HTTP layer in front of the DFX backend. This process answers a fixed set of routes itself (`/version`, a filtered swagger snapshot, short-TTL GET cache, optional Postgres reads for country/language). Every other request is forwarded to `BACKEND_URL` without this repository listing those routes.
## Run
@@ -10,7 +10,18 @@ Public HTTP layer in front of the DFX backend. This process answers a fixed set
BACKEND_URL=http://127.0.0.1:3000 node server.js
```
-Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS`, `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`, `QUOTE_BOOK_REFRESH` (`1` to enable the quote poller; off by default). `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
+Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 15000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
+
+## Local answers
+
+- `GET /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`)
+- `GET /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json` — filtered swagger snapshot from the backend; empty snapshot returns 503
+- Short-TTL GET/HEAD cache (default 15s) for `/` and the public list prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
+- Optional Postgres reads for `GET /v1/country` and `GET /v1/language` when `SQL_HOST` is set
+
+Only fresh cache hits are served. After the TTL the next request fetches again. If that fetch cannot reach the backend, the response is 503 — never an expired cache body. A still-fresh cache hit is served without calling the backend.
+
+Everything else, including quotes, is reverse-proxied to `BACKEND_URL`. WebSocket upgrades are tunnelled the same way.
## Images
diff --git a/offered-routes.json b/offered-routes.json
index 636572d..c2a2c2b 100644
--- a/offered-routes.json
+++ b/offered-routes.json
@@ -41,7 +41,11 @@
"method": "GET",
"path": "/swagger",
"match": "exact",
- "aliases": ["/swagger/", "/swagger-ui", "/swagger-ui/"],
+ "aliases": [
+ "/swagger/",
+ "/swagger-ui",
+ "/swagger-ui/"
+ ],
"usedIn": [
{
"unidentified": true,
@@ -59,7 +63,9 @@
"method": "GET",
"path": "/swagger-json",
"match": "exact",
- "aliases": ["/swagger-json/"],
+ "aliases": [
+ "/swagger-json/"
+ ],
"usedIn": [
{
"unidentified": true,
@@ -73,65 +79,6 @@
}
]
},
- {
- "method": "PUT",
- "path": "/v1/buy/quote",
- "match": "exact",
- "usedIn": [
- {
- "repo": "DFXswiss/packages",
- "path": "packages/core/src/client/BuyApi.ts",
- "note": "BuyApi.quote PUTs BuyUrl.quote (buy/quote) without a session token."
- },
- {
- "repo": "DFXswiss/dfx-wallet",
- "path": "src/features/dfx-backend/services/payment-service.ts",
- "note": "Wallet payment service PUTs /v1/buy/quote."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "DFXswiss/services e2e-stack/specs/buy.spec.ts exercises PUT /v1/buy/paymentInfos, not this RAM quote path. A frontend E2E that PUTs /v1/buy/quote is required before changing this path."
- }
- ]
- },
- {
- "method": "PUT",
- "path": "/v1/sell/quote",
- "match": "exact",
- "usedIn": [
- {
- "repo": "DFXswiss/packages",
- "path": "packages/core/src/client/SellApi.ts",
- "note": "SellApi.quote PUTs SellUrl.quote (sell/quote)."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "DFXswiss/services e2e-stack/specs/sell-swap.spec.ts is the widget sell frontend; it has not been shown to PUT /v1/sell/quote (vs payment-info paths). A frontend E2E that PUTs this path is required before changing it."
- }
- ]
- },
- {
- "method": "PUT",
- "path": "/v1/swap/quote",
- "match": "exact",
- "usedIn": [
- {
- "repo": "DFXswiss/packages",
- "path": "packages/core/src/client/SwapApi.ts",
- "note": "SwapApi.quote PUTs SwapUrl.quote (swap/quote)."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "A frontend E2E that PUTs /v1/swap/quote is required before changing this path."
- }
- ]
- },
{
"method": "GET",
"path": "/v1/asset",
@@ -314,154 +261,6 @@
"note": "No frontend E2E named that hits /v1/app live. Required before changing this prefix."
}
]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/quote/buyPrice",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. RealUnitCH/app calls the parallel /v1/realunit/brokerbot/buyPrice, not this /quote/ path."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/quote/buyShares",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. RealUnitCH/app calls /v1/realunit/brokerbot/buyShares, not this /quote/ path."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/quote/info",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. No named public call site in RealUnitCH/app or DFXswiss/services."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/quote/price",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. No named public call site in RealUnitCH/app or DFXswiss/services."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/brokerbot/buyPrice",
- "match": "exact",
- "usedIn": [
- {
- "repo": "RealUnitCH/app",
- "path": "lib/packages/service/dfx/dfx_brokerbot_service.dart",
- "note": "DfxBrokerbotService._buyPricePath."
- },
- {
- "repo": "RealUnitCH/app",
- "path": "lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart",
- "note": "Buy converter calls getBuyPrice."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "RealUnitCH/app has widget/golden tests for the buy page, not a Maestro/device E2E that hits this path live. Required before changing this path."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/brokerbot/buyShares",
- "match": "exact",
- "usedIn": [
- {
- "repo": "RealUnitCH/app",
- "path": "lib/packages/service/dfx/dfx_brokerbot_service.dart",
- "note": "DfxBrokerbotService._buySharesPath."
- },
- {
- "repo": "RealUnitCH/app",
- "path": "lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart",
- "note": "Buy converter calls getBuyShares."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "Widget/golden tests exist; Maestro/device E2E that hits this path live is required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/brokerbot/info",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. RealUnitCH/app brokerbot client does not call /brokerbot/info."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
- },
- {
- "method": "GET",
- "path": "/v1/realunit/brokerbot/price",
- "match": "exact",
- "usedIn": [
- {
- "unidentified": true,
- "note": "RAM GET. RealUnitCH/app brokerbot client does not call /brokerbot/price."
- }
- ],
- "e2e": [
- {
- "unidentified": true,
- "note": "No frontend E2E named that GETs this path live. Required before changing it."
- }
- ]
}
]
}
diff --git a/server.js b/server.js
index b798373..ad86532 100644
--- a/server.js
+++ b/server.js
@@ -21,7 +21,6 @@ const PORT = +(orFallback(process.env.PORT, 3000));
const BIND = orFallback(process.env.BIND, '0.0.0.0');
const BACKEND = process.env.BACKEND_URL;
const TTL_MS = +(orFallback(process.env.CACHE_TTL_MS, 15000));
-const QUOTE_TTL_MS = 300000;
const CACHE_MAX = +(orFallback(process.env.CACHE_MAX, 500));
const REQUEST_TIMEOUT_MS = +(orFallback(process.env.REQUEST_TIMEOUT_MS, 20000));
const STARTED = new Date().toISOString();
@@ -41,7 +40,6 @@ const CACHE_PREFIXES = [
];
const cache = new Map();
-const quoteBook = { buy: new Map(), sell: new Map(), swap: new Map(), realunit: new Map(), filledAt: 0 };
let swaggerSpec = null;
let pool = null;
@@ -102,19 +100,15 @@ function attachRequestTimeout(req, ms, onTimeout) {
req.setTimeout(ms, onTimeout);
}
-function httpJson(method, urlPath, body) {
+function getBackendJson(urlPath) {
return new Promise((resolve, reject) => {
const target = new URL(BACKEND);
- const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
const req = http.request(
{
hostname: target.hostname,
port: backendPortFor(target),
path: urlPath,
- method,
- headers: payload
- ? { 'content-type': 'application/json', 'content-length': payload.length }
- : {},
+ method: 'GET',
},
(resp) => {
const chunks = [];
@@ -134,37 +128,10 @@ function httpJson(method, urlPath, body) {
req.destroy();
reject(new Error('timeout'));
});
- if (payload) req.write(payload);
req.end();
});
}
-function pairKey(kind, body) {
- const cur = (body && body.currency && (body.currency.id || body.currency.name)) || '';
- const src =
- (body && body.sourceAsset && (body.sourceAsset.id || body.sourceAsset.name)) ||
- (body && body.asset && (body.asset.id || body.asset.uniqueName || body.asset.name)) ||
- '';
- const target = (body && body.targetAsset && (body.targetAsset.id || body.targetAsset.name)) || '';
- const pm = (body && body.paymentMethod) || 'Bank';
- return [kind, cur, src, target, pm].join('|');
-}
-
-function rememberQuote(map, kind, rec, variants) {
- for (const body of variants) map.set(pairKey(kind, body), rec);
-}
-
-const RAM_GET_PATHS = [
- '/v1/realunit/quote/buyPrice',
- '/v1/realunit/quote/buyShares',
- '/v1/realunit/quote/info',
- '/v1/realunit/quote/price',
- '/v1/realunit/brokerbot/buyPrice',
- '/v1/realunit/brokerbot/buyShares',
- '/v1/realunit/brokerbot/info',
- '/v1/realunit/brokerbot/price',
-];
-
const EXACT_GET_PATHS = [
'/',
'/version',
@@ -176,153 +143,15 @@ const EXACT_GET_PATHS = [
'/swagger-ui/',
];
-const EXACT_PUT_PATHS = ['/v1/buy/quote', '/v1/sell/quote', '/v1/swap/quote'];
-
function isServedPath(path) {
const p = (path || '/').split('?')[0];
- if (EXACT_GET_PATHS.includes(p) || EXACT_PUT_PATHS.includes(p) || RAM_GET_PATHS.includes(p)) return true;
+ if (EXACT_GET_PATHS.includes(p)) return true;
return CACHE_PREFIXES.some((pref) => p === pref || p.startsWith(pref + '/'));
}
-function scaleQuote(stored, body) {
- const out = JSON.parse(JSON.stringify(stored.json));
- const rate = Number(out.rate);
- const wantsScale = body.amount != null || body.targetAmount != null;
- if (wantsScale && !(rate > 0)) return null;
- if (body.amount != null) {
- out.amount = body.amount;
- out.estimatedAmount = body.amount / rate;
- if (out.fees && typeof out.fees.rate === 'number') {
- out.feeAmount = body.amount * (out.fees.rate || 0) + (out.fees.fixed || 0);
- }
- } else if (body.targetAmount != null) {
- out.estimatedAmount = body.targetAmount;
- out.amount = body.targetAmount * rate;
- if (out.fees && typeof out.fees.rate === 'number') {
- out.feeAmount = out.amount * (out.fees.rate || 0) + (out.fees.fixed || 0);
- }
- }
- return out;
-}
-
-function isQuoteFresh(stored) {
- return !!(stored && stored.json && typeof stored.at === 'number' && Date.now() - stored.at <= QUOTE_TTL_MS);
-}
-
-async function refreshQuoteBook() {
- try {
- const assets = (await httpJson('GET', '/v1/asset')).json;
- const fiats = (await httpJson('GET', '/v1/fiat')).json;
- if (!Array.isArray(assets) || !Array.isArray(fiats)) return;
- const named = ['CHF', 'EUR', 'USD']
- .map((n) => fiats.find((f) => f.name === n))
- .filter((f) => f && f.id);
- if (!named.find((f) => f.name === 'CHF')) {
- console.error('quote book refresh: no CHF, book unchanged');
- return;
- }
- const buy = new Map();
- const sell = new Map();
- const swap = new Map();
- const realunit = new Map();
- const buyable = assets.filter((a) => a.buyable).slice(0, 12);
- const sellable = assets.filter((a) => a.sellable).slice(0, 8);
- for (const fiat of named) {
- const methods = fiat.name === 'CHF' ? ['Bank', 'Instant'] : ['Bank'];
- for (const pm of methods) {
- for (const asset of buyable) {
- const body = { currency: { id: fiat.id }, asset: { id: asset.id }, amount: 100, paymentMethod: pm };
- try {
- const got = await httpJson('PUT', '/v1/buy/quote', body);
- if (got.status === 200 && got.json) {
- const rec = { json: got.json, at: Date.now() };
- rememberQuote(buy, 'buy', rec, [
- body,
- { currency: { name: fiat.name }, asset: { id: asset.id }, paymentMethod: pm },
- { currency: { id: fiat.id }, asset: { name: asset.name }, paymentMethod: pm },
- { currency: { name: fiat.name }, asset: { name: asset.name }, paymentMethod: pm },
- ...(asset.uniqueName
- ? [
- { currency: { id: fiat.id }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
- { currency: { name: fiat.name }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
- ]
- : []),
- ]);
- }
- } catch (err) {
- console.error('quote refresh buy', fiat.name, asset.id, pm, err.message);
- }
- }
- for (const asset of sellable) {
- const body = { currency: { id: fiat.id }, asset: { id: asset.id }, amount: 0.01, paymentMethod: pm };
- try {
- const got = await httpJson('PUT', '/v1/sell/quote', body);
- if (got.status === 200 && got.json) {
- const rec = { json: got.json, at: Date.now() };
- rememberQuote(sell, 'sell', rec, [
- body,
- { currency: { name: fiat.name }, asset: { id: asset.id }, paymentMethod: pm },
- { currency: { id: fiat.id }, asset: { name: asset.name }, paymentMethod: pm },
- { currency: { name: fiat.name }, asset: { name: asset.name }, paymentMethod: pm },
- ...(asset.uniqueName
- ? [
- { currency: { id: fiat.id }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
- { currency: { name: fiat.name }, asset: { uniqueName: asset.uniqueName }, paymentMethod: pm },
- ]
- : []),
- ]);
- }
- } catch (err) {
- console.error('quote refresh sell', fiat.name, asset.id, pm, err.message);
- }
- }
- }
- }
- const swapSrc = buyable[0];
- const swapDst = buyable.find((a) => a.id !== (swapSrc && swapSrc.id));
- if (swapSrc && swapDst) {
- const body = { sourceAsset: { id: swapSrc.id }, targetAsset: { id: swapDst.id }, amount: 0.01 };
- try {
- const got = await httpJson('PUT', '/v1/swap/quote', body);
- if (got.status === 200 && got.json) {
- const rec = { json: got.json, at: Date.now() };
- rememberQuote(swap, 'swap', rec, [
- body,
- { sourceAsset: { name: swapSrc.name }, targetAsset: { id: swapDst.id }, amount: 0.01 },
- { sourceAsset: { id: swapSrc.id }, targetAsset: { name: swapDst.name }, amount: 0.01 },
- { sourceAsset: { name: swapSrc.name }, targetAsset: { name: swapDst.name }, amount: 0.01 },
- ]);
- }
- } catch (err) {
- console.error('quote refresh swap', err.message);
- }
- }
- for (const p of RAM_GET_PATHS) {
- try {
- const got = await httpJson('GET', p);
- if (got.status === 200 && got.json) realunit.set(p, { json: got.json, at: Date.now() });
- } catch (err) {
- console.error('quote refresh realunit', p, err.message);
- }
- }
- if (buy.size === 0 && sell.size === 0) {
- console.error('quote book refresh: empty book, keeping previous');
- return;
- }
- if (buy.size > 0) quoteBook.buy = buy;
- if (sell.size > 0) quoteBook.sell = sell;
- if (swap.size > 0) quoteBook.swap = swap;
- if (realunit.size > 0) quoteBook.realunit = realunit;
- quoteBook.filledAt = Date.now();
- console.log('quote book buy', quoteBook.buy.size, 'sell', quoteBook.sell.size, 'ru', quoteBook.realunit.size);
- } catch (err) {
- console.error('quote book refresh', err.message);
- }
-}
-
async function refreshSwagger() {
try {
- const got = await httpJson('GET', '/swagger-json');
+ const got = await getBackendJson('/swagger-json');
if (!got.json || !got.json.paths) return;
const paths = {};
for (const [p, ops] of Object.entries(got.json.paths)) {
@@ -350,21 +179,6 @@ window.ui = SwaggerUIBundle({ url: '/swagger-json', dom_id: '#swagger-ui' });
`;
}
-function readBody(req) {
- return new Promise((resolve, reject) => {
- const chunks = [];
- req.on('data', (c) => chunks.push(c));
- req.on('end', () => {
- try {
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
- } catch (err) {
- reject(err);
- }
- });
- req.on('error', reject);
- });
-}
-
function sendJson(res, status, body, via) {
let buf;
if (Buffer.isBuffer(body)) {
@@ -468,7 +282,7 @@ async function tryDbRead(path) {
return Buffer.from(JSON.stringify(spec.map(result.rows)));
}
-function proxy(req, res, stale) {
+function proxy(req, res) {
const target = new URL(BACKEND);
const opts = {
hostname: target.hostname,
@@ -494,20 +308,14 @@ function proxy(req, res, stale) {
});
p.on('error', (err) => {
console.error('proxy error', err.message);
- if (stale && !res.headersSent) {
- const headers = { ...stale.headers, 'x-front-api': 'stale' };
- res.writeHead(stale.status, headers);
- res.end(stale.body);
- return;
- }
if (!res.headersSent) {
res.writeHead(503, {
'content-type': 'application/json',
'retry-after': '30',
'access-control-allow-origin': '*',
});
+ res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
}
- res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
});
attachRequestTimeout(p, REQUEST_TIMEOUT_MS, () => {
p.destroy();
@@ -542,54 +350,20 @@ const server = http.createServer((req, res) => {
return;
}
- const quoteKind =
- path === '/v1/buy/quote' ? 'buy' : path === '/v1/sell/quote' ? 'sell' : path === '/v1/swap/quote' ? 'swap' : null;
- if (quoteKind && req.method === 'PUT') {
- readBody(req)
- .then((body) => {
- const stored = quoteBook[quoteKind].get(pairKey(quoteKind, body));
- if (!isQuoteFresh(stored)) {
- sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
- return;
- }
- const scaled = scaleQuote(stored, body);
- if (!scaled) {
- sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
- return;
- }
- sendJson(res, 200, scaled, 'ram');
- })
- .catch(() => sendJson(res, 400, { statusCode: 400, message: 'invalid json' }, 'local'));
- return;
- }
-
- if (req.method === 'GET' && RAM_GET_PATHS.includes(path)) {
- const hitRu = quoteBook.realunit.get(path);
- if (!isQuoteFresh(hitRu)) {
- sendJson(res, 503, { statusCode: 503, message: 'quote unavailable', retryAfter: 30 }, 'local');
- return;
- }
- sendJson(res, 200, hitRu.json, 'ram');
- return;
- }
-
const key = cacheKey(req);
const hit = isCacheable(req) ? getCached(key) : null;
- const fresh = hit && Date.now() <= hit.exp ? hit : null;
- if (fresh) {
- const headers = { ...fresh.headers, 'x-front-api': 'hit' };
- res.writeHead(fresh.status, headers);
- res.end(fresh.body);
+ if (hit && Date.now() <= hit.exp) {
+ const headers = { ...hit.headers, 'x-front-api': 'hit' };
+ res.writeHead(hit.status, headers);
+ res.end(hit.body);
return;
}
- const stale = hit && Date.now() > hit.exp ? hit : null;
-
if (pool && req.method === 'GET' && !req.headers.authorization && DB_READ[path]) {
tryDbRead(path)
.then((body) => {
if (!body) {
- proxy(req, res, stale);
+ proxy(req, res);
return;
}
putCache(key, 200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, body);
@@ -597,18 +371,12 @@ const server = http.createServer((req, res) => {
})
.catch((err) => {
console.error('db-read', path, err.message);
- if (stale) {
- const headers = { ...stale.headers, 'x-front-api': 'stale' };
- res.writeHead(stale.status, headers);
- res.end(stale.body);
- return;
- }
- proxy(req, res, stale);
+ proxy(req, res);
});
return;
}
- proxy(req, res, stale);
+ proxy(req, res);
});
server.on('upgrade', (req, socket, head) => {
@@ -639,12 +407,6 @@ function boot() {
console.log(`front-api listening on ${BIND}:${PORT}` + (pool ? ' db-read on' : ''));
refreshSwagger();
setInterval(refreshSwagger, 10 * 60 * 1000).unref();
- if (process.env.QUOTE_BOOK_REFRESH === '1') {
- refreshQuoteBook();
- setInterval(refreshQuoteBook, 60 * 1000).unref();
- } else {
- console.log('quote book refresh disabled (QUOTE_BOOK_REFRESH=1 to enable)');
- }
});
}
@@ -681,22 +443,13 @@ function getPool() {
module.exports = {
orFallback,
backendPortFor,
- QUOTE_TTL_MS,
CACHE_MAX,
CACHE_PREFIXES,
- RAM_GET_PATHS,
EXACT_GET_PATHS,
- EXACT_PUT_PATHS,
- quoteBook,
cache,
- pairKey,
- isQuoteFresh,
isServedPath,
isCacheable,
cacheKey,
- scaleQuote,
- rememberQuote,
- refreshQuoteBook,
refreshSwagger,
swaggerHtml,
countryDto,
@@ -708,7 +461,6 @@ module.exports = {
localVersion,
sendJson,
sendVersion,
- readBody,
proxy,
attachRequestTimeout,
setSwaggerSpec,
diff --git a/test/offered-routes.test.js b/test/offered-routes.test.js
index c31d251..ff77ed2 100644
--- a/test/offered-routes.test.js
+++ b/test/offered-routes.test.js
@@ -16,7 +16,7 @@ const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
if (!Array.isArray(catalog.routes) || catalog.routes.length === 0) fail('catalog.routes must be a non-empty array');
process.env.BACKEND_URL = process.env.BACKEND_URL || 'http://127.0.0.1:9';
-const { CACHE_PREFIXES, RAM_GET_PATHS, EXACT_GET_PATHS, EXACT_PUT_PATHS, isServedPath } = require('../server.js');
+const { CACHE_PREFIXES, EXACT_GET_PATHS, isServedPath } = require('../server.js');
const PUBLIC_REPOS = new Set([
'DFXswiss/services',
@@ -24,7 +24,7 @@ const PUBLIC_REPOS = new Set([
'DFXswiss/dfx-wallet',
'RealUnitCH/app',
]);
-const METHODS = new Set(['GET', 'PUT']);
+const METHODS = new Set(['GET']);
function namesOf(row) {
return [row.path].concat(Array.isArray(row.aliases) ? row.aliases : []);
@@ -78,21 +78,13 @@ for (const row of catalog.routes) {
const exactGetNames = new Set();
for (const row of catalog.routes) {
- if (row.method === 'GET' && row.match === 'exact' && !RAM_GET_PATHS.includes(row.path)) {
+ if (row.method === 'GET' && row.match === 'exact') {
for (const n of namesOf(row)) exactGetNames.add(n);
}
}
for (const p of EXACT_GET_PATHS) {
if (!exactGetNames.has(p)) fail('served GET path missing from exact catalog names: ' + p);
}
-for (const p of EXACT_PUT_PATHS) {
- const row = rowFor('PUT', p, 'exact');
- if (!row) fail('PUT quote missing as exact row: ' + p);
-}
-for (const p of RAM_GET_PATHS) {
- const row = rowFor('GET', p, 'exact');
- if (!row) fail('RAM GET missing as exact row: ' + p);
-}
for (const p of CACHE_PREFIXES) {
const row = rowFor('GET', p, 'prefix');
if (!row) fail('CACHE_PREFIX missing as prefix row: ' + p);
@@ -101,8 +93,6 @@ for (const p of CACHE_PREFIXES) {
const expectedKeys = new Set();
for (const p of CACHE_PREFIXES) expectedKeys.add('GET ' + p);
-for (const p of RAM_GET_PATHS) expectedKeys.add('GET ' + p);
-for (const p of EXACT_PUT_PATHS) expectedKeys.add('PUT ' + p);
for (const p of ['/', '/version', '/swagger', '/swagger-json']) expectedKeys.add('GET ' + p);
for (const key of seen) {
if (!expectedKeys.has(key)) fail('unexpected catalog row: ' + key);
diff --git a/test/server.test.js b/test/server.test.js
index ef4eee0..9715006 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -154,20 +154,12 @@ async function main() {
const s = require(serverJs);
const {
- QUOTE_TTL_MS,
CACHE_MAX,
CACHE_PREFIXES,
- RAM_GET_PATHS,
- quoteBook,
cache,
- pairKey,
- isQuoteFresh,
isServedPath,
isCacheable,
cacheKey,
- scaleQuote,
- rememberQuote,
- refreshQuoteBook,
refreshSwagger,
swaggerHtml,
countryDto,
@@ -201,34 +193,17 @@ async function main() {
if (backendPortFor(new URL('http://127.0.0.1:9')) !== 9) fail('backendPort set');
if (backendPortFor(new URL('http://127.0.0.1')) !== 80) fail('backendPort 80');
if (backendPortFor(new URL('https://example.com')) !== 443) fail('backendPort 443');
- if (!(QUOTE_TTL_MS > 0) || !(CACHE_MAX > 0)) fail('constants');
+ if (!(CACHE_MAX > 0)) fail('constants');
if (!CACHE_PREFIXES.includes('/v1/asset')) fail('CACHE_PREFIXES');
- if (!RAM_GET_PATHS.includes('/v1/realunit/quote/price')) fail('RAM_GET_PATHS');
if (getPool() !== null) fail('pool default');
- if (!isQuoteFresh({ json: { ok: 1 }, at: Date.now() })) fail('isQuoteFresh fresh');
- if (isQuoteFresh({ json: { ok: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 })) fail('isQuoteFresh stale');
- if (isQuoteFresh(undefined) || isQuoteFresh({ json: { ok: 1 } })) fail('isQuoteFresh miss');
-
- if (pairKey('buy', {}) !== 'buy||||Bank') fail('pairKey empty');
- if (pairKey('buy', { currency: { name: 'CHF' }, asset: { uniqueName: 'BTC' } }).indexOf('CHF') < 0) {
- fail('pairKey uniqueName');
- }
- if (pairKey('swap', { sourceAsset: { id: 1 }, targetAsset: { name: 'ETH' } }).indexOf('ETH') < 0) {
- fail('pairKey swap');
- }
- if (pairKey('buy', { currency: { id: 1 }, asset: { name: 'BTC' }, paymentMethod: 'Instant' }).indexOf('Instant') < 0) {
- fail('pairKey instant');
- }
- if (pairKey('buy', { currency: { id: 1 }, sourceAsset: { name: 'A' } }).indexOf('A') < 0) fail('pairKey source');
-
if (!isServedPath('/version') || !isServedPath('/swagger/') || !isServedPath('/swagger-json/')) fail('isServedPath meta');
if (!isServedPath('/swagger-ui') || !isServedPath('/swagger-ui/')) fail('isServedPath ui');
- if (!isServedPath('/v1/buy/quote') || !isServedPath('/v1/sell/quote') || !isServedPath('/v1/swap/quote')) {
+ if (isServedPath('/v1/buy/quote') || isServedPath('/v1/sell/quote') || isServedPath('/v1/swap/quote')) {
fail('isServedPath quotes');
}
if (!isServedPath('/v1/asset/1') || !isServedPath(undefined)) fail('isServedPath');
- if (!isServedPath('/v1/realunit/quote/price')) fail('isServedPath ram');
+ if (isServedPath('/v1/realunit/quote/price')) fail('isServedPath ram');
if (isServedPath('/v1/user')) fail('isServedPath user');
if (!isCacheable({ method: 'GET', url: '/v1/asset', headers: {} })) fail('cache GET');
@@ -241,24 +216,6 @@ async function main() {
if (isCacheable({ method: 'GET', url: '/v1/user', headers: {} })) fail('cache user');
if (cacheKey({ method: 'GET', url: '/a' }) !== 'GET /a') fail('cacheKey');
- const scaledAmt = scaleQuote({ json: { rate: 2, fees: { rate: 0.01, fixed: 1 } } }, { amount: 100 });
- if (scaledAmt.estimatedAmount !== 50 || scaledAmt.feeAmount !== 2) fail('scale amount');
- const scaledTgt = scaleQuote({ json: { rate: 2, fees: { rate: 0.01 } } }, { targetAmount: 10 });
- if (scaledTgt.amount !== 20) fail('scale target');
- if (scaleQuote({ json: { rate: 0 } }, { amount: 1 }) !== null) fail('scale zero');
- if (scaleQuote({ json: { rate: 2 } }, {}).rate !== 2) fail('scale none');
- if (scaleQuote({ json: { rate: 2, fees: { rate: 'x' } } }, { amount: 10 }).feeAmount !== undefined) {
- fail('scale fees type');
- }
- const scaledZeroFee = scaleQuote({ json: { rate: 2, fees: { rate: 0, fixed: 0 } } }, { amount: 10 });
- if (scaledZeroFee.feeAmount !== 0) fail('scale fee fallback');
- const scaledZeroFeeT = scaleQuote({ json: { rate: 2, fees: { rate: 0, fixed: 0 } } }, { targetAmount: 10 });
- if (scaledZeroFeeT.feeAmount !== 0) fail('scale fee fallback target');
-
- const m = new Map();
- rememberQuote(m, 'buy', { json: { rate: 1 }, at: 1 }, [{ currency: { id: 1 }, asset: { id: 2 } }]);
- if (m.size !== 1) fail('rememberQuote');
-
if (swaggerHtml().indexOf('swagger-ui') < 0) fail('swaggerHtml');
if (localVersion().commit !== 'front-api') fail('localVersion');
const hi = highlightJson({ a: 'b&<>', k: 'v' });
@@ -327,10 +284,6 @@ async function main() {
if ((await tryDbRead('/v1/country')) !== null) fail('tryDbRead null result');
setPool(null);
- await refreshQuoteBook();
- if (quoteBook.buy.size === 0 || quoteBook.sell.size === 0 || quoteBook.realunit.size === 0) {
- fail('refreshQuoteBook empty');
- }
await refreshSwagger();
if (!getSwaggerSpec() || !getSwaggerSpec().paths['/v1/asset'] || getSwaggerSpec().paths['/v1/user']) {
fail('refreshSwagger allowlist');
@@ -338,41 +291,16 @@ async function main() {
const port = await listen(server);
try {
- quoteBook.realunit.clear();
- let got = await request(port, 'GET', '/v1/realunit/quote/price');
- if (got.status !== 503) fail('ram_miss');
- quoteBook.realunit.set('/v1/realunit/quote/price', { json: { price: 1 }, at: Date.now() - QUOTE_TTL_MS - 1 });
- got = await request(port, 'GET', '/v1/realunit/quote/price');
- if (got.status !== 503) fail('ram_stale');
- quoteBook.realunit.set('/v1/realunit/quote/price', { json: { price: 42 }, at: Date.now() });
- got = await request(port, 'GET', '/v1/realunit/quote/price');
- if (got.status !== 200 || got.headers['x-front-api'] !== 'ram') fail('ram_fresh');
- quoteBook.realunit.set('/v1/realunit/brokerbot/price', { json: { price: 3 }, at: Date.now() });
- got = await request(port, 'GET', '/v1/realunit/brokerbot/price');
- if (got.status !== 200) fail('ram brokerbot');
-
const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
- quoteBook.buy.clear();
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 503) fail('buy miss');
- quoteBook.buy.set(pairKey('buy', buyBody), { json: { rate: 2 }, at: Date.now() });
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 200) fail('buy fresh');
- quoteBook.buy.set(pairKey('buy', buyBody), { json: { rate: 0 }, at: Date.now() });
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 503) fail('buy zero');
- got = await request(port, 'PUT', '/v1/buy/quote', Buffer.from('not-json'));
- if (got.status !== 400) fail('buy invalid json');
- got = await request(port, 'PUT', '/v1/buy/quote', Buffer.alloc(0));
- if (got.status !== 503 && got.status !== 200) fail('buy empty body');
-
- quoteBook.sell.set(pairKey('sell', buyBody), { json: { rate: 2 }, at: Date.now() });
+ let got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy buy body');
got = await request(port, 'PUT', '/v1/sell/quote', buyBody);
- if (got.status !== 200) fail('sell');
+ if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy sell body');
const swapBody = { sourceAsset: { id: 1 }, targetAsset: { id: 2 }, amount: 0.01 };
- quoteBook.swap.set(pairKey('swap', swapBody), { json: { rate: 2 }, at: Date.now() });
got = await request(port, 'PUT', '/v1/swap/quote', swapBody);
- if (got.status !== 200) fail('swap');
+ if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy swap body');
+ got = await request(port, 'GET', '/v1/realunit/quote/price');
+ if (got.status !== 200 || got.body.indexOf('"price":1') < 0) fail('quote_proxy realunit');
setSwaggerSpec(null);
got = await request(port, 'GET', '/swagger-json');
@@ -433,7 +361,8 @@ async function main() {
},
});
got = await request(port, 'GET', '/v1/language');
- if (got.headers['x-front-api'] !== 'stale') fail('db catch stale');
+ if (got.headers['x-front-api'] === 'stale') fail('db catch must not serve stale');
+ if (got.body.includes('{"s":1}')) fail('db catch must not replay expired cache');
cache.delete('GET /v1/language');
cache.clear();
got = await request(port, 'GET', '/v1/language');
@@ -496,7 +425,7 @@ async function main() {
fakeReq.method = 'GET';
fakeReq.url = '/nope';
fakeReq.headers = {};
- proxy(fakeReq, sent, null);
+ proxy(fakeReq, sent);
const noUrl = fakeRes();
const noUrlReq = new http.IncomingMessage(new net.Socket());
noUrlReq.method = 'GET';
@@ -505,11 +434,16 @@ async function main() {
server.emit('request', noUrlReq, noUrl);
await close(backend);
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
+ if (got.status !== 503) fail('quote_proxy dead backend');
+ if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
+ if (got.body.includes('quote unavailable')) fail('quote_proxy must not say quote unavailable');
cache.clear();
putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"stale":true}'));
cache.get('GET /v1/statistic').exp = Date.now() - 1;
got = await request(port, 'GET', '/v1/statistic');
- if (got.headers['x-front-api'] !== 'stale') fail('proxy stale after backend down');
+ if (got.status !== 503) fail('proxy expired cache after backend down');
+ if (got.body.includes('{"stale":true}')) fail('must not replay expired cache body');
cache.clear();
got = await request(port, 'GET', '/v1/statistic');
if (got.status !== 503) fail('proxy 503 after backend down');
@@ -520,9 +454,9 @@ async function main() {
hang.listen(bPort, '127.0.0.1', resolve);
hang.on('error', reject);
});
- await refreshQuoteBook();
got = await request(port, 'GET', '/v1/coin');
- if (got.status !== 503 && got.headers['x-front-api'] !== 'stale') fail('proxy hang timeout');
+ if (got.status !== 503) fail('proxy hang timeout');
+ await refreshSwagger();
for (const c of heldHang) c.destroy();
await close(hang);
@@ -539,63 +473,6 @@ async function main() {
await close(server);
}
- const emptyBook = http.createServer(jsonHandler({ '/v1/asset': [], '/v1/fiat': [{ id: 10, name: 'CHF' }] }));
- const emptyPort = await listen(emptyBook);
- await new Promise((resolve, reject) => {
- const child = spawn(
- process.execPath,
- [
- '-e',
- `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + emptyPort)};
-const s=require(${JSON.stringify(serverJs)});
-s.refreshQuoteBook().then(()=>process.exit(0));`,
- ],
- { env: childEnv() },
- );
- child.on('exit', () => resolve());
- child.on('error', reject);
- setTimeout(() => child.kill('SIGKILL'), 5000);
- });
- await close(emptyBook);
-
- const noChf = http.createServer(jsonHandler({ '/v1/asset': assets, '/v1/fiat': [{ id: 11, name: 'EUR' }] }));
- const noChfPort = await listen(noChf);
- await new Promise((resolve, reject) => {
- const child = spawn(
- process.execPath,
- [
- '-e',
- `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + noChfPort)};
-const s=require(${JSON.stringify(serverJs)});
-s.refreshQuoteBook().then(()=>process.exit(0));`,
- ],
- { env: childEnv() },
- );
- child.on('exit', () => resolve());
- child.on('error', reject);
- setTimeout(() => child.kill('SIGKILL'), 5000);
- });
- await close(noChf);
-
- const notArr = http.createServer(jsonHandler({ '/v1/asset': { no: 'array' }, '/v1/fiat': fiats }));
- const naPort = await listen(notArr);
- await new Promise((resolve, reject) => {
- const child = spawn(
- process.execPath,
- [
- '-e',
- `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + naPort)};
-const s=require(${JSON.stringify(serverJs)});
-s.refreshQuoteBook().then(()=>process.exit(0));`,
- ],
- { env: childEnv() },
- );
- child.on('exit', () => resolve());
- child.on('error', reject);
- setTimeout(() => child.kill('SIGKILL'), 5000);
- });
- await close(notArr);
-
const badJson = http.createServer((req, res) => {
res.end('not-json');
});
@@ -636,71 +513,6 @@ s.refreshSwagger().then(()=>process.exit(0));`,
});
await close(noPaths);
- const quoteErr = http.createServer((req, res) => {
- const p = (req.url || '/').split('?')[0];
- if (p === '/v1/asset') {
- res.end(JSON.stringify(assets));
- return;
- }
- if (p === '/v1/fiat') {
- res.end(JSON.stringify(fiats));
- return;
- }
- req.destroy();
- });
- const qePort = await listen(quoteErr);
- await new Promise((resolve, reject) => {
- const child = spawn(
- process.execPath,
- [
- '-e',
- `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + qePort)};
-const s=require(${JSON.stringify(serverJs)});
-s.refreshQuoteBook().then(()=>process.exit(0));`,
- ],
- { env: childEnv() },
- );
- child.on('exit', () => resolve());
- child.on('error', reject);
- setTimeout(() => child.kill('SIGKILL'), 8000);
- });
- await close(quoteErr);
-
- const noUnique = http.createServer(
- jsonHandler({
- '/v1/asset': [{ id: 1, name: 'A', buyable: true, sellable: true }],
- '/v1/fiat': [{ id: 10, name: 'CHF' }],
- '/v1/buy/quote': quote,
- '/v1/sell/quote': quote,
- '/v1/swap/quote': { rate: 2 },
- '/v1/realunit/quote/price': ram,
- '/v1/realunit/quote/buyPrice': ram,
- '/v1/realunit/quote/buyShares': ram,
- '/v1/realunit/quote/info': ram,
- '/v1/realunit/brokerbot/buyPrice': ram,
- '/v1/realunit/brokerbot/buyShares': ram,
- '/v1/realunit/brokerbot/info': ram,
- '/v1/realunit/brokerbot/price': ram,
- }),
- );
- const nuPort = await listen(noUnique);
- await new Promise((resolve, reject) => {
- const child = spawn(
- process.execPath,
- [
- '-e',
- `process.env.BACKEND_URL=${JSON.stringify('http://127.0.0.1:' + nuPort)};
-const s=require(${JSON.stringify(serverJs)});
-s.refreshQuoteBook().then(()=>process.exit(0));`,
- ],
- { env: childEnv() },
- );
- child.on('exit', () => resolve());
- child.on('error', reject);
- setTimeout(() => child.kill('SIGKILL'), 8000);
- });
- await close(noUnique);
-
await close(backend);
const missing = spawnSync(process.execPath, [serverJs], {
@@ -783,14 +595,12 @@ process.exit(0);`,
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
- process.env.QUOTE_BOOK_REFRESH = '';
boot();
});
await close(server);
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
- process.env.QUOTE_BOOK_REFRESH = '1';
setPool({ query: async () => ({ rows: [] }) });
boot();
});
@@ -798,7 +608,7 @@ process.exit(0);`,
const listenOff = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [serverJs], {
- env: childEnv({ BACKEND_URL: 'http://127.0.0.1:9', PORT: '0', BIND: '127.0.0.1', QUOTE_BOOK_REFRESH: '' }),
+ env: childEnv({ BACKEND_URL: 'http://127.0.0.1:9', PORT: '0', BIND: '127.0.0.1' }),
});
let out = '';
const done = () => {
@@ -807,7 +617,7 @@ process.exit(0);`,
};
child.stdout.on('data', (d) => {
out += d;
- if (out.indexOf('quote book refresh disabled') >= 0) done();
+ if (out.indexOf('listening') >= 0) done();
});
child.stderr.on('data', (d) => {
out += d;
@@ -818,7 +628,7 @@ process.exit(0);`,
resolve(out);
}, 4000);
});
- if (listenOff.indexOf('quote book refresh disabled') < 0) fail('listen off: ' + listenOff);
+ if (listenOff.indexOf('listening') < 0) fail('listen off: ' + listenOff);
const listenOn = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [serverJs], {
@@ -826,7 +636,6 @@ process.exit(0);`,
BACKEND_URL: 'http://127.0.0.1:9',
PORT: '0',
BIND: '127.0.0.1',
- QUOTE_BOOK_REFRESH: '1',
SQL_HOST: '127.0.0.1',
SQL_PORT: '5432',
SQL_DB: 'db',
diff --git a/test/test-server.sh b/test/test-server.sh
index 07f1155..f4d3a3f 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -2,12 +2,10 @@
# Pin test + 100% coverage gate for production JS (c8).
#
# Arms:
-# RAM miss → 503 ram_miss
-# RAM stale (older than QUOTE_TTL_MS) → 503 ram_stale
-# RAM fresh → 200 ram_fresh
# swagger snapshot empty → 503 local body swagger_empty
+# PUT /v1/buy/quote → 503 backend unavailable quote_proxy
# attachRequestTimeout → callback + destroy proxy_timeout
-# poller default off (QUOTE_BOOK_REFRESH!==1) poller_off
+# no in-memory quotes / stale cache quotes_gone
# c8 100% lines/functions/branches/statements coverage_100
# c8 --all includes every new production .js file coverage_all
set -euo pipefail
@@ -25,13 +23,19 @@ fail() {
[ -f "$server_js" ] || fail "missing: $server_js"
[ -f "$test_js" ] || fail "missing: $test_js"
-grep -q "QUOTE_BOOK_REFRESH === '1'" "$server_js" || fail "poller_off: gate missing"
-grep -q 'refreshQuoteBook();' "$server_js" || fail "poller_off: refresh helper missing"
grep -q 'function isServedPath' "$server_js" || fail "isServedPath missing"
grep -q 'if (!isServedPath(p)) continue' "$server_js" || fail "swagger snapshot must allowlist served paths"
if grep -q 'low.includes' "$server_js"; then
fail "swagger snapshot must not denylist unserved routes"
fi
+for banned in quoteBook refreshQuoteBook QUOTE_BOOK_REFRESH RAM_GET_PATHS scaleQuote isQuoteFresh pairKey rememberQuote EXACT_PUT_PATHS QUOTE_TTL_MS; do
+ if grep -q "$banned" "$server_js"; then
+ fail "server.js must not contain $banned"
+ fi
+done
+if grep -qE "x-front-api': 'stale'|\"x-front-api\": \"stale\"" "$server_js"; then
+ fail "server.js must not serve stale cache"
+fi
c8rc="$repo_root/.c8rc.json"
[ -f "$c8rc" ] || fail "coverage_100: missing .c8rc.json"
From b1de9cf178777acd43bc00660cf4cde9f86fd0d5 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 16:25:19 +0200
Subject: [PATCH 05/15] 01a02b56 - Pin quote forwarding and real TTL cache
expiry (#7)
* 01a02b56 - Pin quote forwarding and real TTL cache expiry
Record mock-backend method, path, body, and content-type.
Expire GET /v1/asset via CACHE_TTL_MS instead of injected cache rows.
* 01a02b56 - Require exactly one forwarded request per quote route
The quote_forward pin must fail on duplicate upstream PUTs or GETs.
* 01a02b56 - Use nullish coalescing for recorded content-type
Match CONTRIBUTING: ?? not || when defaulting a missing header.
* 01a02b56 - Assert cache hit before TTL expiry in the asset pin
Prime GET /v1/asset must be a cache hit before the mock is closed.
* 01a02b56 - Widen CACHE_TTL_MS window for the expiry pin
Two seconds of TTL leaves room for the immediate hit check without
a 200ms race, then the mock is closed after expiry.
---
test/server.test.js | 73 ++++++++++++++++++++++++++++++++++-----------
test/test-server.sh | 5 ++++
2 files changed, 61 insertions(+), 17 deletions(-)
diff --git a/test/server.test.js b/test/server.test.js
index 9715006..27c0973 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -84,22 +84,34 @@ function fakeRes() {
};
}
-function jsonHandler(routes) {
+function jsonHandler(routes, seen) {
return (req, res) => {
- const p = (req.url || '/').split('?')[0];
- const hit = routes[p];
- if (typeof hit === 'function') {
- hit(req, res);
- return;
- }
- if (hit === undefined) {
- res.writeHead(404, { 'content-type': 'application/json' });
- res.end('{}');
- return;
- }
- const body = Buffer.from(typeof hit === 'string' ? hit : JSON.stringify(hit));
- res.writeHead(200, { 'content-type': 'application/json', 'transfer-encoding': 'chunked' });
- res.end(body);
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const p = (req.url || '/').split('?')[0];
+ if (seen) {
+ seen.push({
+ method: req.method,
+ path: p,
+ body: Buffer.concat(chunks).toString('utf8'),
+ contentType: req.headers['content-type'] ?? '',
+ });
+ }
+ const hit = routes[p];
+ if (typeof hit === 'function') {
+ hit(req, res);
+ return;
+ }
+ if (hit === undefined) {
+ res.writeHead(404, { 'content-type': 'application/json' });
+ res.end('{}');
+ return;
+ }
+ const body = Buffer.from(typeof hit === 'string' ? hit : JSON.stringify(hit));
+ res.writeHead(200, { 'content-type': 'application/json', 'transfer-encoding': 'chunked' });
+ res.end(body);
+ });
};
}
@@ -119,6 +131,7 @@ async function main() {
paths: { '/v1/asset': { get: {} }, '/v1/user': { get: {} }, '/version': { get: {} } },
};
+ const seen = [];
const backend = http.createServer(
jsonHandler({
'/v1/asset': assets,
@@ -140,14 +153,14 @@ async function main() {
'/v1/bank': { ok: 1 },
'/v1/app': { ok: 1 },
'/v1/coin': { ok: 1 },
- }),
+ }, seen),
);
const bPort = await listen(backend);
process.env.BACKEND_URL = 'http://127.0.0.1:' + bPort;
process.env.PORT = '0';
process.env.REQUEST_TIMEOUT_MS = '50';
+ process.env.CACHE_TTL_MS = '2000';
delete process.env.BIND;
- delete process.env.CACHE_TTL_MS;
delete process.env.CACHE_MAX;
delete process.env.SQL_HOST;
delete process.env.QUOTE_BOOK_REFRESH;
@@ -302,6 +315,24 @@ async function main() {
got = await request(port, 'GET', '/v1/realunit/quote/price');
if (got.status !== 200 || got.body.indexOf('"price":1') < 0) fail('quote_proxy realunit');
+ const forwarded = seen.filter((row) =>
+ (row.method === 'PUT' &&
+ (row.path === '/v1/buy/quote' || row.path === '/v1/sell/quote' || row.path === '/v1/swap/quote')) ||
+ (row.method === 'GET' && row.path === '/v1/realunit/quote/price'),
+ );
+ if (forwarded.length !== 4) fail('quote_forward: expected exactly 4 recorded requests');
+ const buyFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/buy/quote');
+ const sellFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/sell/quote');
+ const swapFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/swap/quote');
+ const ruFwd = forwarded.find((row) => row.method === 'GET' && row.path === '/v1/realunit/quote/price');
+ if (!buyFwd || !sellFwd || !swapFwd || !ruFwd) fail('quote_forward: method/path');
+ if (JSON.stringify(JSON.parse(buyFwd.body)) !== JSON.stringify(buyBody)) fail('quote_forward: buy body');
+ if (JSON.stringify(JSON.parse(sellFwd.body)) !== JSON.stringify(buyBody)) fail('quote_forward: sell body');
+ if (JSON.stringify(JSON.parse(swapFwd.body)) !== JSON.stringify(swapBody)) fail('quote_forward: swap body');
+ if (buyFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: buy content-type');
+ if (sellFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: sell content-type');
+ if (swapFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: swap content-type');
+
setSwaggerSpec(null);
got = await request(port, 'GET', '/swagger-json');
if (got.status !== 503) fail('swagger empty json');
@@ -433,7 +464,15 @@ async function main() {
noUrlReq.headers = {};
server.emit('request', noUrlReq, noUrl);
+ got = await request(port, 'GET', '/v1/asset');
+ if (got.status !== 200 || got.body.indexOf('BTC') < 0) fail('ttl_expire: prime');
+ got = await request(port, 'GET', '/v1/asset');
+ if (got.headers['x-front-api'] !== 'hit') fail('ttl_expire: cache hit before expiry');
+ await new Promise((r) => setTimeout(r, 2200));
await close(backend);
+ got = await request(port, 'GET', '/v1/asset');
+ if (got.status !== 503) fail('ttl_expire: expected 503');
+ if (got.body.indexOf('BTC') >= 0) fail('ttl_expire: must not replay expired cache body');
got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
if (got.status !== 503) fail('quote_proxy dead backend');
if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
diff --git a/test/test-server.sh b/test/test-server.sh
index f4d3a3f..73a1351 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -4,6 +4,8 @@
# Arms:
# swagger snapshot empty → 503 local body swagger_empty
# PUT /v1/buy/quote → 503 backend unavailable quote_proxy
+# mock records forwarded method/path/body quote_forward
+# expired GET /v1/asset after TTL → 503 ttl_expire
# attachRequestTimeout → callback + destroy proxy_timeout
# no in-memory quotes / stale cache quotes_gone
# c8 100% lines/functions/branches/statements coverage_100
@@ -36,6 +38,9 @@ done
if grep -qE "x-front-api': 'stale'|\"x-front-api\": \"stale\"" "$server_js"; then
fail "server.js must not serve stale cache"
fi
+grep -q 'quote_forward' "$test_js" || fail "quote_forward: pin missing"
+grep -q 'ttl_expire' "$test_js" || fail "ttl_expire: pin missing"
+grep -Fq "CACHE_TTL_MS = '2000'" "$test_js" || fail "ttl_expire: CACHE_TTL_MS pin missing"
c8rc="$repo_root/.c8rc.json"
[ -f "$c8rc" ] || fail "coverage_100: missing .c8rc.json"
From a55f35d5f66532ecdba2c9cdf1e11929029f36b7 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 16:38:52 +0200
Subject: [PATCH 06/15] 01a02b56 - Default GET cache TTL to five minutes (#8)
CACHE_TTL_MS falls back to 300000. The expiry pin still overrides
that in tests so it does not wait five minutes.
---
README.md | 6 +++---
server.js | 2 +-
test/test-server.sh | 2 ++
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index cfc5c03..8e55113 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# front-api
-Public HTTP layer in front of the DFX backend. This process answers a fixed set of routes itself (`/version`, a filtered swagger snapshot, short-TTL GET cache, optional Postgres reads for country/language). Every other request is forwarded to `BACKEND_URL` without this repository listing those routes.
+Public HTTP layer in front of the DFX backend. This process answers a fixed set of routes itself (`/version`, a filtered swagger snapshot, GET cache, optional Postgres reads for country/language). Every other request is forwarded to `BACKEND_URL` without this repository listing those routes.
## Run
@@ -10,13 +10,13 @@ Public HTTP layer in front of the DFX backend. This process answers a fixed set
BACKEND_URL=http://127.0.0.1:3000 node server.js
```
-Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 15000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
+Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
## Local answers
- `GET /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`)
- `GET /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json` — filtered swagger snapshot from the backend; empty snapshot returns 503
-- Short-TTL GET/HEAD cache (default 15s) for `/` and the public list prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
+- GET/HEAD cache (default 5 minutes) for `/` and the public list prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
- Optional Postgres reads for `GET /v1/country` and `GET /v1/language` when `SQL_HOST` is set
Only fresh cache hits are served. After the TTL the next request fetches again. If that fetch cannot reach the backend, the response is 503 — never an expired cache body. A still-fresh cache hit is served without calling the backend.
diff --git a/server.js b/server.js
index ad86532..0977822 100644
--- a/server.js
+++ b/server.js
@@ -20,7 +20,7 @@ if (!process.env.BACKEND_URL) {
const PORT = +(orFallback(process.env.PORT, 3000));
const BIND = orFallback(process.env.BIND, '0.0.0.0');
const BACKEND = process.env.BACKEND_URL;
-const TTL_MS = +(orFallback(process.env.CACHE_TTL_MS, 15000));
+const TTL_MS = +(orFallback(process.env.CACHE_TTL_MS, 300000));
const CACHE_MAX = +(orFallback(process.env.CACHE_MAX, 500));
const REQUEST_TIMEOUT_MS = +(orFallback(process.env.REQUEST_TIMEOUT_MS, 20000));
const STARTED = new Date().toISOString();
diff --git a/test/test-server.sh b/test/test-server.sh
index 73a1351..c41ca4d 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -6,6 +6,7 @@
# PUT /v1/buy/quote → 503 backend unavailable quote_proxy
# mock records forwarded method/path/body quote_forward
# expired GET /v1/asset after TTL → 503 ttl_expire
+# default CACHE_TTL_MS is 5 minutes cache_ttl_default
# attachRequestTimeout → callback + destroy proxy_timeout
# no in-memory quotes / stale cache quotes_gone
# c8 100% lines/functions/branches/statements coverage_100
@@ -41,6 +42,7 @@ fi
grep -q 'quote_forward' "$test_js" || fail "quote_forward: pin missing"
grep -q 'ttl_expire' "$test_js" || fail "ttl_expire: pin missing"
grep -Fq "CACHE_TTL_MS = '2000'" "$test_js" || fail "ttl_expire: CACHE_TTL_MS pin missing"
+grep -Fq 'orFallback(process.env.CACHE_TTL_MS, 300000)' "$server_js" || fail "cache_ttl_default: 5 minutes missing"
c8rc="$repo_root/.c8rc.json"
[ -f "$c8rc" ] || fail "coverage_100: missing .c8rc.json"
From 8c216b18ebcf369c5fb35fab168716dd7a55f07e Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 16:45:10 +0200
Subject: [PATCH 07/15] 01a02b77 - Cap every HTTP response at 100ms (#6)
* Cap every HTTP response at 100ms
Production cuts a late response, emits an ERROR log, and forbids code that cannot meet that bound. The upgrade handshake stays in budget until a completed 101.
* Cut leftover responses at 100ms and reject a zero outbound timeout
A deadline 503 that has not finished flushing is destroyed at the cap. Invalid REQUEST_TIMEOUT_MS values no longer disable the outbound wait.
---
CONTRIBUTING.md | 16 ++
README.md | 7 +-
REVIEW.md | 18 ++-
server.js | 156 ++++++++++++++++--
test/server.test.js | 378 +++++++++++++++++++++++++++++++++++++++++++-
test/test-server.sh | 21 ++-
6 files changed, 570 insertions(+), 26 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4a31ffe..0922dd6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -146,6 +146,16 @@ Missing any applicable item = changes requested.
- Authenticated requests are never answered from the GET cache.
- Quotes are reverse-proxied to `BACKEND_URL`. This process does not keep a quote book.
- A down backend always returns 503. Never serve an expired cache body.
+- Every HTTP response from this process must complete within **100ms**. That
+ bound is technical and always enforced, not a target. The process must cut
+ the request so the client never waits longer (`503` `response deadline
+ exceeded`) and must emit an `ERROR` log. It is **forbidden** to add code
+ that cannot finish in that budget: unbounded awaits, blocking work, uncapped
+ outbound waits, sleeps, or any other path that would let a ping exceed
+ 100ms. The client-facing deadline is always 100ms, including the WebSocket
+ upgrade handshake until a completed `101`. A WebSocket after that handshake
+ is no longer an HTTP response. `REQUEST_TIMEOUT_MS` may only lower the
+ outbound wait to the backend, never raise it above 100ms.
- Do not expose internals in responses (SQL credentials, backend hosts, or
other secrets).
@@ -176,6 +186,12 @@ if CI is green.
There is no production JavaScript in this repository that may ship below 100%
coverage. The coverage gate is the CI job, not a review courtesy.
+There is no HTTP response this process may take longer than 100ms to finish.
+`test/test-server.sh` pins `MAX_RESPONSE_MS = 100`, the inbound deadline, the
+upgrade-handshake budget, the `ERROR` log, and the outbound cap; the Node
+suite rejects any helper round-trip over 100ms. A miss is a red `test` job,
+not a review note.
+
Every path this process answers itself also needs **frontend E2E** coverage:
a real UI flow that hits that function, listed in `offered-routes.json`.
Those tests usually live in the consumer repository (for example
diff --git a/README.md b/README.md
index 8e55113..8d1cb15 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Public HTTP layer in front of the DFX backend. This process answers a fixed set
BACKEND_URL=http://127.0.0.1:3000 node server.js
```
-Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (20000), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
+Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (capped at 100; default 100), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
## Local answers
@@ -23,6 +23,11 @@ Only fresh cache hits are served. After the TTL the next request fetches again.
Everything else, including quotes, is reverse-proxied to `BACKEND_URL`. WebSocket upgrades are tunnelled the same way.
+Every HTTP response must finish within 100ms, including the WebSocket
+upgrade handshake. A slower response is a hard bug: the process answers
+`503` `response deadline exceeded` (or cuts the upgrade socket), emits an
+`ERROR` log, and CI fails. Code that cannot meet that bound is forbidden.
+
## Images
Push to `develop` publishes `dfxswiss/front-api:beta` and the git SHA. Push to `main` publishes `dfxswiss/front-api:latest` and the git SHA. After a successful push the workflow notifies the configured infrastructure repo (`DISPATCH_TOKEN` + `DISPATCH_REPO`). If those secrets are unset, the image is still published.
diff --git a/REVIEW.md b/REVIEW.md
index 80db01f..4d48399 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -16,9 +16,10 @@ This item includes the EN/DE PR-body form and GitHub-verified commits.
## 2. Required CI green on the head SHA
Job `test` is `success` on **exactly this** SHA. That job includes the 100%
-coverage gate (`c8 --check-coverage` on all four metrics) and the offered-route
-catalog check (`test/test-offered-routes.sh`). A coverage miss or a catalog
-miss is a red job, not a review note.
+coverage gate (`c8 --check-coverage` on all four metrics), the offered-route
+catalog check (`test/test-offered-routes.sh`), and the 100ms response
+deadline. A coverage miss, catalog miss, or a helper round-trip over 100ms
+is a red job, not a review note.
- `skipped` does not count as green unless this repository documents that skip
as expected. Today: `test` is not skipped on drafts.
@@ -50,7 +51,7 @@ repository hygiene rule in CONTRIBUTING.md.
## 6. Tests cover the change
New or changed branches in `server.js` (503 vs 200, cache hit/miss, allowlist,
-timeout, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
+timeout, 100ms deadline, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
changes have a pin in `test/test-main-from-develop.sh`. Automatic release-PR
body-form changes have a pin in `test/test-auto-release-pr.sh`. Green CI
without a pin for a behaviour change is fail. Every production `*.js` file
@@ -113,3 +114,12 @@ enough to merge.
Any fail on this item keeps the pull request as a draft or on changes
requested. There is no "follow-up E2E" for a new or changed offered
function unless the reviewer grants that in writing.
+
+## 12. 100ms response deadline
+
+Every HTTP response from this process must finish within 100ms. Fail if
+`MAX_RESPONSE_MS` is not 100, if `REQUEST_TIMEOUT_MS` can exceed 100, if
+the inbound budget is missing, if the upgrade handshake has no deadline,
+if a deadline miss does not emit an `ERROR` log, if the change adds a
+path that cannot finish in 100ms, or if a test round-trip is allowed to
+take longer. A slower ping is a hard bug, not a performance note.
diff --git a/server.js b/server.js
index 0977822..e0afb8c 100644
--- a/server.js
+++ b/server.js
@@ -22,7 +22,13 @@ const BIND = orFallback(process.env.BIND, '0.0.0.0');
const BACKEND = process.env.BACKEND_URL;
const TTL_MS = +(orFallback(process.env.CACHE_TTL_MS, 300000));
const CACHE_MAX = +(orFallback(process.env.CACHE_MAX, 500));
-const REQUEST_TIMEOUT_MS = +(orFallback(process.env.REQUEST_TIMEOUT_MS, 20000));
+const MAX_RESPONSE_MS = 100;
+function outboundTimeoutMs(raw) {
+ const n = +raw;
+ if (!Number.isFinite(n) || n <= 0) return MAX_RESPONSE_MS;
+ return Math.min(MAX_RESPONSE_MS, n);
+}
+const REQUEST_TIMEOUT_MS = outboundTimeoutMs(orFallback(process.env.REQUEST_TIMEOUT_MS, MAX_RESPONSE_MS));
const STARTED = new Date().toISOString();
// Public GET prefixes this layer may answer from cache. Authenticated
@@ -60,8 +66,10 @@ try {
ssl: sslOn ? { rejectUnauthorized: false } : false,
max: 4,
idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 90,
});
pool.on('error', (err) => console.error('pg pool', err.message));
+ attachPoolGuards(pool);
}
} catch (err) {
console.error('pg init failed:', err.message);
@@ -100,6 +108,109 @@ function attachRequestTimeout(req, ms, onTimeout) {
req.setTimeout(ms, onTimeout);
}
+function canWrite(res) {
+ return !res.headersSent && !res.writableEnded && !res.destroyed;
+}
+
+function logDeadlineError(req) {
+ console.error('ERROR response exceeded ' + MAX_RESPONSE_MS + 'ms', req.method, req.url);
+}
+
+function isUpgradeHandshakeComplete(headerBlock) {
+ if (headerBlock.indexOf('\r\n\r\n') < 0) return false;
+ const statusLine = headerBlock.slice(0, headerBlock.indexOf('\r\n'));
+ return statusLine.split(' ')[1] === '101';
+}
+
+function attachResponseBudget(req, res, budgetMs) {
+ const asked = budgetMs === undefined ? MAX_RESPONSE_MS : budgetMs;
+ const limit = Math.min(MAX_RESPONSE_MS, asked);
+ const fireAt = Math.max(1, limit - 10);
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ };
+ const timer = setTimeout(() => {
+ if (settled) return;
+ logDeadlineError(req);
+ if (!res.headersSent) {
+ sendJson(res, 503, { statusCode: 503, message: 'response deadline exceeded', retryAfter: 1 }, 'local', {
+ connection: 'close',
+ 'retry-after': '1',
+ });
+ return;
+ }
+ if (!res.destroyed) req.destroy();
+ }, fireAt);
+ const hard = setTimeout(() => {
+ if (settled) return;
+ logDeadlineError(req);
+ if (!res.destroyed) req.destroy();
+ finish();
+ }, limit);
+ timer.unref();
+ hard.unref();
+ res.on('finish', finish);
+ res.on('close', finish);
+ return true;
+}
+
+function attachUpgradeBudget(req, socket, up, budgetMs) {
+ if (socket.destroyed) {
+ if (!up.destroyed) up.destroy();
+ return true;
+ }
+ const asked = budgetMs === undefined ? MAX_RESPONSE_MS : budgetMs;
+ const limit = Math.min(MAX_RESPONSE_MS, asked);
+ const fireAt = Math.max(1, limit - 10);
+ let settled = false;
+ let header = '';
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ up.removeListener('data', onData);
+ header = '';
+ };
+ const onData = (chunk) => {
+ header += chunk.toString('latin1');
+ if (isUpgradeHandshakeComplete(header)) finish();
+ };
+ const timer = setTimeout(() => {
+ if (settled) return;
+ logDeadlineError(req);
+ if (!up.destroyed) up.destroy();
+ if (!socket.destroyed) socket.destroy();
+ finish();
+ }, fireAt);
+ timer.unref();
+ up.on('data', onData);
+ socket.once('close', () => {
+ if (!up.destroyed) up.destroy();
+ finish();
+ });
+ up.once('close', () => {
+ if (!socket.destroyed) socket.destroy();
+ finish();
+ });
+ return true;
+}
+
+function onPoolConnect(client) {
+ return client.query('SET statement_timeout TO 90');
+}
+
+function attachPoolGuards(p) {
+ p.on('connect', (client) => {
+ Promise.resolve(onPoolConnect(client)).catch((err) => {
+ console.error('pg statement_timeout', err.message);
+ if (typeof client.release === 'function') client.release(true);
+ else if (typeof client.end === 'function') client.end();
+ });
+ });
+ return p;
+}
+
function getBackendJson(urlPath) {
return new Promise((resolve, reject) => {
const target = new URL(BACKEND);
@@ -179,7 +290,8 @@ window.ui = SwaggerUIBundle({ url: '/swagger-json', dom_id: '#swagger-ui' });
`;
}
-function sendJson(res, status, body, via) {
+function sendJson(res, status, body, via, extraHeaders) {
+ if (!canWrite(res)) return;
let buf;
if (Buffer.isBuffer(body)) {
try {
@@ -190,13 +302,13 @@ function sendJson(res, status, body, via) {
} else {
buf = Buffer.from(JSON.stringify(body, null, 2) + '\n');
}
- res.writeHead(status, {
+ res.writeHead(status, Object.assign({
'content-type': 'application/json; charset=utf-8',
'content-length': buf.length,
'x-content-type-options': 'nosniff',
'x-front-api': via,
'access-control-allow-origin': '*',
- });
+ }, extraHeaders || {}));
res.end(buf);
}
@@ -210,6 +322,7 @@ function highlightJson(obj) {
}
function sendVersion(req, res, obj, via) {
+ if (!canWrite(res)) return;
if (String(req.headers.accept || '').includes('text/html')) {
const html = Buffer.from(
'' +
@@ -283,6 +396,7 @@ async function tryDbRead(path) {
}
function proxy(req, res) {
+ if (!canWrite(res)) return;
const target = new URL(BACKEND);
const opts = {
hostname: target.hostname,
@@ -295,6 +409,7 @@ function proxy(req, res) {
const chunks = [];
up.on('data', (c) => chunks.push(c));
up.on('end', () => {
+ if (!canWrite(res)) return;
const body = Buffer.concat(chunks);
const headers = { ...up.headers };
delete headers['transfer-encoding'];
@@ -308,22 +423,23 @@ function proxy(req, res) {
});
p.on('error', (err) => {
console.error('proxy error', err.message);
- if (!res.headersSent) {
- res.writeHead(503, {
- 'content-type': 'application/json',
- 'retry-after': '30',
- 'access-control-allow-origin': '*',
- });
- res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
- }
+ if (!canWrite(res)) return;
+ res.writeHead(503, {
+ 'content-type': 'application/json',
+ 'retry-after': '30',
+ 'access-control-allow-origin': '*',
+ });
+ res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
});
attachRequestTimeout(p, REQUEST_TIMEOUT_MS, () => {
p.destroy();
});
+ res.on('finish', () => p.destroy());
req.pipe(p);
}
const server = http.createServer((req, res) => {
+ attachResponseBudget(req, res);
const path = (req.url || '/').split('?')[0];
if (path === '/version' && req.method === 'GET') {
sendVersion(req, res, localVersion(), 'local');
@@ -336,6 +452,7 @@ const server = http.createServer((req, res) => {
return;
}
const html = Buffer.from(swaggerHtml());
+ if (!canWrite(res)) return;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': html.length, 'x-front-api': 'local' });
res.end(html);
return;
@@ -353,6 +470,7 @@ const server = http.createServer((req, res) => {
const key = cacheKey(req);
const hit = isCacheable(req) ? getCached(key) : null;
if (hit && Date.now() <= hit.exp) {
+ if (!canWrite(res)) return;
const headers = { ...hit.headers, 'x-front-api': 'hit' };
res.writeHead(hit.status, headers);
res.end(hit.body);
@@ -383,6 +501,10 @@ server.on('upgrade', (req, socket, head) => {
const target = new URL(BACKEND);
const port = backendPortFor(target);
const up = net.connect(port, target.hostname, () => {
+ if (socket.destroyed) {
+ up.destroy();
+ return;
+ }
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
const headers = { ...req.headers, host: target.host };
for (const [k, v] of Object.entries(headers)) {
@@ -398,6 +520,7 @@ server.on('upgrade', (req, socket, head) => {
up.pipe(socket);
socket.pipe(up);
});
+ attachUpgradeBudget(req, socket, up);
up.on('error', () => socket.destroy());
socket.on('error', () => up.destroy());
});
@@ -463,6 +586,15 @@ module.exports = {
sendVersion,
proxy,
attachRequestTimeout,
+ attachResponseBudget,
+ attachUpgradeBudget,
+ attachPoolGuards,
+ onPoolConnect,
+ canWrite,
+ logDeadlineError,
+ isUpgradeHandshakeComplete,
+ MAX_RESPONSE_MS,
+ outboundTimeoutMs,
setSwaggerSpec,
getSwaggerSpec,
setPool,
diff --git a/test/server.test.js b/test/server.test.js
index 27c0973..a261e92 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -3,6 +3,8 @@
const http = require('http');
const net = require('net');
const path = require('path');
+const { EventEmitter } = require('events');
+const { Readable } = require('stream');
const { spawn, spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..');
@@ -36,6 +38,7 @@ function close(srv) {
function request(port, method, urlPath, body, headers) {
return new Promise((resolve, reject) => {
+ const t0 = Date.now();
const payload =
body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
const req = http.request(
@@ -53,10 +56,16 @@ function request(port, method, urlPath, body, headers) {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
+ const ms = Date.now() - t0;
+ if (ms > 100) {
+ reject(new Error('slow ' + method + ' ' + urlPath + ' ' + ms + 'ms'));
+ return;
+ }
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString('utf8'),
headers: res.headers,
+ ms,
});
});
},
@@ -67,6 +76,31 @@ function request(port, method, urlPath, body, headers) {
});
}
+function mockReqRes() {
+ const req = new EventEmitter();
+ req.method = 'GET';
+ req.url = '/x';
+ req.destroy = () => {
+ req.destroyed = true;
+ };
+ const res = new EventEmitter();
+ res.headersSent = false;
+ res.writeHead = function writeHead(status, headers) {
+ this.status = status;
+ this.headers = headers;
+ this.headersSent = true;
+ };
+ res.end = function end(body) {
+ this.body = body;
+ this.emit('finish');
+ };
+ return { req, res };
+}
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
function fakeRes() {
return {
headersSent: false,
@@ -81,6 +115,8 @@ function fakeRes() {
end(body) {
this.body = body;
},
+ on() {},
+ emit() {},
};
}
@@ -185,6 +221,14 @@ async function main() {
sendJson,
sendVersion,
attachRequestTimeout,
+ attachResponseBudget,
+ attachUpgradeBudget,
+ attachPoolGuards,
+ onPoolConnect,
+ canWrite,
+ isUpgradeHandshakeComplete,
+ MAX_RESPONSE_MS,
+ outboundTimeoutMs,
setSwaggerSpec,
getSwaggerSpec,
setPool,
@@ -199,7 +243,23 @@ async function main() {
} = s;
if (maybeExitAfterBoot() !== false) fail('maybeExitAfterBoot off');
+ if (MAX_RESPONSE_MS !== 100) fail('MAX_RESPONSE_MS');
+ const endedRes = fakeRes();
+ endedRes.writableEnded = true;
+ if (canWrite(endedRes)) fail('canWrite ended');
+ const deadRes = fakeRes();
+ deadRes.destroyed = true;
+ if (canWrite(deadRes)) fail('canWrite destroyed');
if (REQUEST_TIMEOUT_MS !== 50) fail('REQUEST_TIMEOUT_MS env');
+ if (REQUEST_TIMEOUT_MS > MAX_RESPONSE_MS) fail('REQUEST_TIMEOUT_MS cap');
+ if (outboundTimeoutMs(0) !== 100 || outboundTimeoutMs(-1) !== 100 || outboundTimeoutMs('nope') !== 100) {
+ fail('outboundTimeoutMs invalid');
+ }
+ if (outboundTimeoutMs(50) !== 50 || outboundTimeoutMs(20000) !== 100) fail('outboundTimeoutMs cap');
+ if (!isUpgradeHandshakeComplete('HTTP/1.1 101 Switching Protocols\r\n\r\n')) fail('handshake 101');
+ if (isUpgradeHandshakeComplete('HTTP/1.1 101\r\n')) fail('handshake incomplete');
+ if (isUpgradeHandshakeComplete('HTTP/1.1 400 Bad Request\r\n\r\n')) fail('handshake 400');
+ if (!isUpgradeHandshakeComplete('HTTP/1.1 101\r\n\r\n')) fail('handshake 101 end');
if (orFallback('', 'x') !== 'x' || orFallback('a', 'x') !== 'a') fail('orFallback');
if (orFallback(undefined, 'x') !== 'x' || orFallback(null, 'x') !== 'x') fail('orFallback nullish');
const { URL } = require('url');
@@ -304,6 +364,34 @@ async function main() {
const port = await listen(server);
try {
+ const blocked = fakeRes();
+ blocked.headersSent = true;
+ sendVersion({ headers: { accept: 'text/html' } }, blocked, localVersion(), 'local');
+ const mkReq = (urlPath) => {
+ const r = new http.IncomingMessage(new net.Socket());
+ r.method = 'GET';
+ r.url = urlPath;
+ r.headers = {};
+ return r;
+ };
+ server.emit('request', mkReq('/swagger'), blocked);
+ putCache('GET /v1/asset', 200, { 'content-type': 'application/json' }, Buffer.from('[]'));
+ server.emit('request', mkReq('/v1/asset'), blocked);
+ proxy(mkReq('/v1/statistic'), blocked);
+ const raceRes = fakeRes();
+ const piped = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ piped.method = 'GET';
+ piped.url = '/v1/asset';
+ piped.headers = { host: '127.0.0.1' };
+ piped.destroy = () => {};
+ proxy(piped, raceRes);
+ raceRes.headersSent = true;
+ await sleep(50);
+
const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
let got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy buy body');
@@ -422,21 +510,32 @@ async function main() {
hanging.on('error', reject);
});
- await new Promise((resolve) => {
+ await new Promise((resolve, reject) => {
+ const t0 = Date.now();
+ let settled = false;
const sock = net.connect(port, '127.0.0.1', () => {
sock.write(
'GET /socket HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-A: 1\r\nX-A: 2\r\n\r\n',
);
});
- sock.on('error', () => resolve());
- sock.on('data', () => {
+ const done = (err) => {
+ if (settled) return;
+ settled = true;
+ const ms = Date.now() - t0;
sock.destroy();
+ if (err) {
+ reject(err);
+ return;
+ }
+ if (ms > 100) {
+ reject(new Error('slow upgrade ' + ms + 'ms'));
+ return;
+ }
resolve();
- });
- setTimeout(() => {
- sock.destroy();
- resolve();
- }, 300);
+ };
+ sock.on('error', () => done());
+ sock.on('data', () => done());
+ setTimeout(() => done(new Error('upgrade hang')), 100);
});
const liveUp = net.connect({ port: bPort, host: '127.0.0.1' });
@@ -450,6 +549,28 @@ async function main() {
await new Promise((r) => setTimeout(r, 50));
liveUp.destroy();
+ const deadClient = new net.Socket();
+ deadClient.destroy();
+ server.emit(
+ 'upgrade',
+ { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x' } },
+ deadClient,
+ null,
+ );
+ const raceClient = new EventEmitter();
+ raceClient.destroyed = false;
+ raceClient.destroy = function destroy() {
+ this.destroyed = true;
+ };
+ server.emit(
+ 'upgrade',
+ { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x' } },
+ raceClient,
+ Buffer.alloc(0),
+ );
+ raceClient.destroyed = true;
+ await sleep(30);
+
const sent = fakeRes();
sent.headersSent = true;
const fakeReq = new http.IncomingMessage(new net.Socket());
@@ -473,6 +594,19 @@ async function main() {
got = await request(port, 'GET', '/v1/asset');
if (got.status !== 503) fail('ttl_expire: expected 503');
if (got.body.indexOf('BTC') >= 0) fail('ttl_expire: must not replay expired cache body');
+ const deadProxy = fakeRes();
+ const deadPipe = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ deadPipe.method = 'GET';
+ deadPipe.url = '/v1/statistic';
+ deadPipe.headers = { host: '127.0.0.1' };
+ deadPipe.destroy = () => {};
+ proxy(deadPipe, deadProxy);
+ deadProxy.headersSent = true;
+ await sleep(50);
got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
if (got.status !== 503) fail('quote_proxy dead backend');
if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
@@ -613,6 +747,234 @@ process.exit(0);`,
);
if (sqlPlain.status !== 0) fail('sql plain ' + (sqlPlain.stderr || sqlPlain.status));
+ const { req: dReq, res: dRes } = mockReqRes();
+ attachResponseBudget(dReq, dRes, 15);
+ await sleep(40);
+ if (dRes.status !== 503) fail('deadline 503');
+ if (String(dRes.body).indexOf('response deadline exceeded') < 0) fail('deadline body');
+ if (!dRes.headers || dRes.headers.connection !== 'close') fail('deadline 503 connection close');
+ if (dReq.destroyed) fail('deadline 503 must not destroy before flush');
+
+ const { req: kReq, res: kRes } = mockReqRes();
+ kRes.end = function end(body) {
+ this.body = body;
+ };
+ attachResponseBudget(kReq, kRes, 15);
+ await sleep(40);
+ if (kRes.status !== 503) fail('hard cut 503');
+ if (!kReq.destroyed) fail('hard cut after 503 without finish');
+
+ const { req: hReq, res: hRes } = mockReqRes();
+ attachResponseBudget(hReq, hRes, 15);
+ hRes.writeHead(200, {});
+ await sleep(40);
+ if (!hReq.destroyed) fail('deadline after headers');
+
+ const { req: eReq, res: eRes } = mockReqRes();
+ eRes.headersSent = true;
+ eRes.writableEnded = true;
+ attachResponseBudget(eReq, eRes, 15);
+ await sleep(40);
+ if (!eReq.destroyed) fail('deadline must cut ended-but-unfinished drain');
+
+ const { req: zReq, res: zRes } = mockReqRes();
+ zRes.headersSent = true;
+ zRes.destroyed = true;
+ attachResponseBudget(zReq, zRes, 15);
+ await sleep(40);
+ if (zReq.destroyed) fail('deadline must not destroy already-destroyed response');
+
+ function mockSock() {
+ const sock = new EventEmitter();
+ sock.destroyed = false;
+ sock.destroy = function destroy() {
+ this.destroyed = true;
+ this.emit('close');
+ };
+ return sock;
+ }
+ const hangClient = mockSock();
+ const hangUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, hangClient, hangUp, 15);
+ await sleep(40);
+ if (!hangClient.destroyed || !hangUp.destroyed) fail('upgrade deadline');
+
+ function mockSockQuiet() {
+ const sock = new EventEmitter();
+ sock.destroyed = false;
+ sock.destroy = function destroy() {
+ this.destroyed = true;
+ };
+ return sock;
+ }
+ const quietClient = mockSockQuiet();
+ const quietUp = mockSockQuiet();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, quietClient, quietUp, 15);
+ await sleep(40);
+ if (!quietClient.destroyed || !quietUp.destroyed) fail('upgrade timer destroy pair');
+
+ const okClient = mockSock();
+ const okUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, okClient, okUp, 15);
+ okUp.emit('data', Buffer.from('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n'));
+ okUp.emit('data', Buffer.from('more'));
+ await sleep(40);
+ if (okClient.destroyed || okUp.destroyed) fail('upgrade handshake ok');
+ if (okUp.listenerCount('data') !== 0) fail('upgrade data listener after handshake');
+
+ const splitClient = mockSock();
+ const splitUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, splitClient, splitUp, 15);
+ splitUp.emit('data', Buffer.from('HTTP/1.1 101\r\n'));
+ splitUp.emit('data', Buffer.from('\r\n'));
+ await sleep(40);
+ if (splitClient.destroyed || splitUp.destroyed) fail('upgrade handshake split headers');
+
+ const partClient = mockSock();
+ const partUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, partClient, partUp, 15);
+ partUp.emit('data', Buffer.from('HTTP/1.1 101\r\n'));
+ await sleep(40);
+ if (!partClient.destroyed || !partUp.destroyed) fail('upgrade incomplete handshake');
+
+ const badClient = mockSock();
+ const badUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, badClient, badUp, 15);
+ badUp.emit('data', Buffer.from('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'));
+ await sleep(40);
+ if (!badClient.destroyed || !badUp.destroyed) fail('upgrade non-101 must not lift deadline');
+
+ const closeClient = mockSock();
+ const closeUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, closeClient, closeUp, 15);
+ closeUp.emit('data', Buffer.from('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'));
+ closeUp.emit('close');
+ if (!closeClient.destroyed) fail('non-101 up close must cut client');
+
+ const closedClient = mockSock();
+ const closedUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, closedClient, closedUp, 15);
+ closedClient.emit('close');
+ await sleep(40);
+ if (!closedUp.destroyed) fail('upgrade close must drop backend');
+
+ const loggedPg = [];
+ const origPgErr = console.error;
+ console.error = function error(...args) {
+ loggedPg.push(args.join(' '));
+ };
+ const fakePool = new EventEmitter();
+ attachPoolGuards(fakePool);
+ fakePool.emit('connect', { query: () => Promise.resolve() });
+ let dropped = false;
+ fakePool.emit('connect', {
+ query: () => Promise.reject(new Error('no timeout')),
+ release(force) {
+ dropped = force === true;
+ },
+ });
+ fakePool.emit('connect', {
+ query: () => Promise.reject(new Error('no timeout')),
+ end() {
+ dropped = true;
+ },
+ });
+ await onPoolConnect({ query: () => Promise.resolve('ok') });
+ await sleep(20);
+ console.error = origPgErr;
+ if (!loggedPg.some((line) => line.indexOf('pg statement_timeout') >= 0)) fail('pool statement_timeout error');
+ if (!dropped) fail('pool SET fail must drop client');
+
+ const deadClientSock = mockSock();
+ const deadUpSock = mockSock();
+ deadClientSock.destroyed = true;
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, deadClientSock, deadUpSock, 15);
+ if (!deadUpSock.destroyed) fail('upgrade must drop backend if client already dead');
+
+ const deadBothClient = mockSock();
+ const deadBothUp = mockSock();
+ deadBothClient.destroyed = true;
+ deadBothUp.destroyed = true;
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, deadBothClient, deadBothUp, 15);
+
+ const skipClient = mockSock();
+ const skipUp = mockSock();
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, skipClient, skipUp, 15);
+ skipClient.destroyed = true;
+ skipUp.destroyed = true;
+ await sleep(40);
+
+ const closedDeadClient = mockSock();
+ const closedDeadUp = mockSock();
+ closedDeadUp.destroyed = true;
+ attachUpgradeBudget({ method: 'GET', url: '/socket' }, closedDeadClient, closedDeadUp, 15);
+ closedDeadClient.emit('close');
+ await sleep(40);
+
+ const { req: fReq, res: fRes } = mockReqRes();
+ attachResponseBudget(fReq, fRes, 20);
+ sendJson(fRes, 200, { ok: 1 }, 'local');
+ if (fRes.status !== 200) fail('budget fast send');
+ await sleep(40);
+ sendJson(fRes, 500, { ok: 0 }, 'local');
+ if (fRes.status !== 200) fail('sendJson after headers');
+ if (canWrite(fRes)) fail('canWrite after send');
+
+ const logged = [];
+ const origErr = console.error;
+ console.error = function error(...args) {
+ logged.push(args.join(' '));
+ };
+ const { req: sReq, res: sRes } = mockReqRes();
+ attachResponseBudget(sReq, sRes);
+ await sleep(120);
+ console.error = origErr;
+ if (sRes.status !== 503) fail('default deadline 503');
+ if (!logged.some((line) => line.indexOf('ERROR response exceeded') >= 0)) fail('over budget ERROR log');
+
+ const cap = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+delete process.env.REQUEST_TIMEOUT_MS;
+const s=require(${JSON.stringify(serverJs)});
+if(s.MAX_RESPONSE_MS!==100) process.exit(2);
+if(s.REQUEST_TIMEOUT_MS!==100) process.exit(3);
+process.exit(0);`,
+ ],
+ { env: childEnv({ REQUEST_TIMEOUT_MS: '' }), encoding: 'utf8', timeout: 3000 },
+ );
+ if (cap.status !== 0) fail('MAX_RESPONSE_MS cap default: ' + cap.status);
+
+ const capHigh = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+process.env.REQUEST_TIMEOUT_MS='20000';
+const s=require(${JSON.stringify(serverJs)});
+if(s.REQUEST_TIMEOUT_MS!==100) process.exit(2);
+process.exit(0);`,
+ ],
+ { env: childEnv({ REQUEST_TIMEOUT_MS: '20000' }), encoding: 'utf8', timeout: 3000 },
+ );
+ if (capHigh.status !== 0) fail('REQUEST_TIMEOUT_MS must not exceed 100: ' + capHigh.status);
+
+ const capZero = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `process.env.BACKEND_URL='http://127.0.0.1:9';
+process.env.REQUEST_TIMEOUT_MS='0';
+const s=require(${JSON.stringify(serverJs)});
+if(s.REQUEST_TIMEOUT_MS!==100) process.exit(2);
+process.exit(0);`,
+ ],
+ { env: childEnv({ REQUEST_TIMEOUT_MS: '0' }), encoding: 'utf8', timeout: 3000 },
+ );
+ if (capZero.status !== 0) fail('REQUEST_TIMEOUT_MS=0 must not disable timeout: ' + capZero.status);
+
const pgThrow = spawnSync(
process.execPath,
['-r', path.join(repoRoot, 'test', 'preload-pg-throw.js'), serverJs],
diff --git a/test/test-server.sh b/test/test-server.sh
index c41ca4d..ae47ee7 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -9,6 +9,7 @@
# default CACHE_TTL_MS is 5 minutes cache_ttl_default
# attachRequestTimeout → callback + destroy proxy_timeout
# no in-memory quotes / stale cache quotes_gone
+# every HTTP response ≤ 100ms max_response_100
# c8 100% lines/functions/branches/statements coverage_100
# c8 --all includes every new production .js file coverage_all
set -euo pipefail
@@ -63,7 +64,25 @@ grep -q 'npm ci' "$wf" || fail "coverage_100: CI must npm ci"
grep -q 'package-lock.json' "$repo_root/Dockerfile" || fail "coverage_100: image must use lockfile"
grep -q 'npm ci --omit=dev' "$repo_root/Dockerfile" || fail "coverage_100: image must npm ci omit dev"
grep -q 'require.main === module' "$server_js" || fail "boot only when main"
-grep -Fq 'orFallback(process.env.REQUEST_TIMEOUT_MS, 20000)' "$server_js" || fail "REQUEST_TIMEOUT_MS default"
+grep -Fq 'MAX_RESPONSE_MS = 100' "$server_js" || fail "max_response_100: constant missing"
+grep -q 'response deadline exceeded' "$server_js" || fail "max_response_100: deadline 503 missing"
+grep -Fq 'REQUEST_TIMEOUT_MS = outboundTimeoutMs(' "$server_js" || fail "max_response_100: REQUEST_TIMEOUT_MS must cap at MAX_RESPONSE_MS"
+grep -Fq 'if (!Number.isFinite(n) || n <= 0)' "$server_js" || fail "max_response_100: outbound timeout 0/NaN must not disable the cap"
+grep -q 'connectionTimeoutMillis: 90' "$server_js" || fail "max_response_100: pool acquire must not outlive the deadline"
+grep -Fq 'orFallback(process.env.REQUEST_TIMEOUT_MS, MAX_RESPONSE_MS)' "$server_js" || fail "REQUEST_TIMEOUT_MS default cap"
+grep -q 'attachResponseBudget(req, res)' "$server_js" || fail "max_response_100: inbound budget missing"
+grep -q 'attachUpgradeBudget(req, socket, up)' "$server_js" || fail "max_response_100: upgrade handshake budget missing"
+grep -Fq 'ERROR response exceeded' "$server_js" || fail "max_response_100: production must ERROR-log a deadline miss"
+grep -q 'isUpgradeHandshakeComplete' "$server_js" || fail "max_response_100: upgrade must settle only on a completed 101"
+grep -Fq "up.removeListener('data', onData)" "$server_js" || fail "max_response_100: handshake listener must not outlive the HTTP upgrade"
+grep -q "SET statement_timeout TO 90" "$server_js" || fail "max_response_100: pool queries must not outlive the deadline"
+grep -q 'limit - 10' "$server_js" || fail "max_response_100: fire before 100ms so the 503 still finishes in budget"
+grep -Fq 'if (!canWrite(res)) return;' "$server_js" || fail "max_response_100: writers must refuse after the deadline"
+grep -Fq 'if (!res.destroyed) req.destroy();' "$server_js" || fail "max_response_100: deadline must cut an unfinished drain"
+grep -Fq "connection: 'close'" "$server_js" || fail "max_response_100: deadline 503 must close the connection"
+grep -Fq "res.on('finish', () => p.destroy())" "$server_js" || fail "max_response_100: proxy must drop outbound when the response finishes"
+grep -q 'forbidden' "$repo_root/CONTRIBUTING.md" || fail "max_response_100: CONTRIBUTING must forbid code that cannot meet 100ms"
+grep -q 'ERROR' "$repo_root/CONTRIBUTING.md" || fail "max_response_100: CONTRIBUTING must require an ERROR log on a deadline miss"
grep -q 'FRONT_API_EXIT_AFTER_BOOT=1' "$repo_root/test/run-main-coverage.sh" || fail "coverage_100: require.main collection missing"
grep -q 'coverage:report' "$pkg" || fail "coverage_100: coverage:report script missing"
grep -q -- '--check-coverage' "$pkg" || fail "coverage_100: check-coverage missing from package.json"
From 9d4ce102af54f8136c9bf80d78640a66e27ae057 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 20:30:35 +0200
Subject: [PATCH 08/15] Add one-command local start with a loopback HTTP stub
(#10)
npm start boots a 127.0.0.1 stub and then this process so the public
repo can run locally without an upstream backend. Setting BACKEND_URL
skips the stub. Production server.js is unchanged.
---
.c8rc.json | 2 +-
CONTRIBUTING.md | 17 +++-
README.md | 26 +++++-
package.json | 1 +
scripts/local-backend.js | 178 +++++++++++++++++++++++++++++++++++++
scripts/start-local.js | 76 ++++++++++++++++
test/local-start.test.js | 184 +++++++++++++++++++++++++++++++++++++++
test/test-server.sh | 18 ++++
8 files changed, 496 insertions(+), 6 deletions(-)
create mode 100644 scripts/local-backend.js
create mode 100644 scripts/start-local.js
create mode 100644 test/local-start.test.js
diff --git a/.c8rc.json b/.c8rc.json
index f87916b..bfb8e60 100644
--- a/.c8rc.json
+++ b/.c8rc.json
@@ -1,7 +1,7 @@
{
"all": true,
"include": ["**/*.js"],
- "exclude": ["test/**", "coverage/**", "node_modules/**"],
+ "exclude": ["test/**", "coverage/**", "node_modules/**", "scripts/**"],
"temp-directory": "coverage/tmp",
"lines": 100,
"functions": 100,
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0922dd6..4523f1f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,6 +17,16 @@ this file was applied fully and correctly.
## Build & Test
+Use `npm start` for a one-command local start. It starts a loopback HTTP stub and then this process; setting `BACKEND_URL` uses that upstream HTTP backend without starting the stub. This convenience command does not replace the test suite, which remains the four bash commands documented below.
+
+The 100% coverage gate applies to production JavaScript. The `scripts/` directory contains local start helpers and is not copied into the Docker image, so c8 excludes `scripts/**`. Production `server.js` must not be excluded, and production code must not be listed in `--exclude`.
+
+The complete c8 exclude list is:
+
+```text
+--exclude='test/**' --exclude='coverage/**' --exclude='node_modules/**' --exclude='scripts/**'
+```
+
The required suite is the GitHub Actions job `test`. It runs `npm ci`, then
`bash test/test-server.sh` (behaviour pins **and** the 100% coverage gate),
`bash test/test-offered-routes.sh` (usage catalog vs the served-path
@@ -35,11 +45,12 @@ bash test/test-auto-release-pr.sh
Every production JavaScript file must stay at **100% statement, branch,
function and line coverage**. CI enforces this with `c8 --check-coverage`
-(see `.c8rc.json`: `--all --include='**/*.js' --exclude='test/**' --exclude='coverage/**' --exclude='node_modules/**'`,
+(see `.c8rc.json`: `--all --include='**/*.js' --exclude='test/**' --exclude='coverage/**' --exclude='node_modules/**' --exclude='scripts/**'`,
and 100 on all four metrics). A result below 100% on any metric turns the
`test` job red. `--all` plus that include/exclude pulls every new `*.js` file
-outside `test/` into the report at 0% until tests exist: adding a script
-without tests fails CI. Production code must not be listed in `--exclude`.
+outside `test/` and `scripts/` into the report at 0% until tests exist.
+Production code must not be listed in `--exclude`; `scripts/` remains the
+documented exception exclude.
## Git & PRs
diff --git a/README.md b/README.md
index 8d1cb15..ba85293 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,34 @@ Public HTTP layer in front of the DFX backend. This process answers a fixed set
## Run
-`BACKEND_URL` is required.
+After cloning the repository, start the front API and its loopback-only HTTP stub with one command:
```bash
-BACKEND_URL=http://127.0.0.1:3000 node server.js
+npm start
```
+The default path needs no dependency installation: the start helpers use only the Node.js standard library, and `server.js` loads `pg` only when `SQL_HOST` is set. Run `npm ci` when you want to use the test suite.
+
+By default, the front API listens on `http://127.0.0.1:3000` and the stub listens on `http://127.0.0.1:3004`. Set `BACKEND_URL` to use an upstream HTTP backend and skip the local stub:
+
+```bash
+BACKEND_URL=http://127.0.0.1:4000 npm start
+```
+
+Local start settings:
+
+- `PORT` sets the front API port and defaults to `3000`.
+- `BIND` sets the front API bind address and defaults to `127.0.0.1` for `npm start`.
+- `LOCAL_BACKEND_PORT` sets the stub port and defaults to `3004`.
+
+Direct production start does not create a stub and remains available with an explicit upstream HTTP backend:
+
+```bash
+BACKEND_URL=http://127.0.0.1:4000 node server.js
+```
+
+Direct `node server.js` continues to default `BIND` to `0.0.0.0`; the loopback bind default applies only to `npm start`.
+
Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `CACHE_MAX`, `REQUEST_TIMEOUT_MS` (capped at 100; default 100), `SQL_HOST` / `SQL_PORT` / `SQL_DB` / `SQL_USERNAME` / `SQL_PASSWORD` / `SQL_SSL`. `FRONT_API_EXIT_AFTER_BOOT=1` is for the coverage collection run only: the process exits shortly after listen.
## Local answers
diff --git a/package.json b/package.json
index 0ca06d9..53d4115 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"version": "0.1.0",
"main": "server.js",
"scripts": {
+ "start": "node scripts/start-local.js",
"test": "bash test/test-server.sh && bash test/test-offered-routes.sh && bash test/test-main-from-develop.sh && bash test/test-auto-release-pr.sh",
"coverage:report": "c8 --check-coverage report"
},
diff --git a/scripts/local-backend.js b/scripts/local-backend.js
new file mode 100644
index 0000000..8a1822d
--- /dev/null
+++ b/scripts/local-backend.js
@@ -0,0 +1,178 @@
+'use strict';
+
+const http = require('http');
+
+const DEFAULT_BIND = '127.0.0.1';
+const DEFAULT_PORT = 3004;
+// Do not require server.js here: it exits when BACKEND_URL is missing.
+const PREFIXES = [
+ '/v1/asset',
+ '/v1/fiat',
+ '/v1/country',
+ '/v1/language',
+ '/v1/statistic',
+ '/v1/coin',
+ '/v1/setting',
+ '/v1/bank',
+ '/v1/app',
+];
+
+const fixtures = {
+ '/v1/asset': [
+ {
+ id: 1,
+ name: 'BTC',
+ uniqueName: 'Bitcoin',
+ buyable: true,
+ sellable: true,
+ },
+ ],
+ '/v1/fiat': [
+ {
+ id: 1,
+ name: 'EUR',
+ buyable: true,
+ sellable: true,
+ },
+ ],
+ '/v1/country': [
+ {
+ id: 1,
+ symbol: 'CH',
+ name: 'Switzerland',
+ foreignName: 'Switzerland',
+ locationAllowed: true,
+ ibanAllowed: true,
+ kycAllowed: true,
+ kycOrganizationAllowed: true,
+ nationalityAllowed: true,
+ bankAllowed: true,
+ cardAllowed: true,
+ cryptoAllowed: true,
+ },
+ ],
+ '/v1/language': [
+ {
+ id: 1,
+ name: 'English',
+ symbol: 'EN',
+ foreignName: 'English',
+ enable: true,
+ },
+ ],
+ '/v1/statistic': { volume: 0 },
+ '/v1/coin': [{ id: 1 }],
+ '/v1/setting': { infoBanner: null },
+ '/v1/bank': [{ id: 1, name: 'Test Bank' }],
+ '/v1/app': { version: 'local' },
+};
+
+function orFallback(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ return value;
+}
+
+function sendJson(res, body, method) {
+ const json = JSON.stringify(body);
+ res.writeHead(200, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'access-control-allow-origin': '*',
+ });
+ res.end(method === 'HEAD' ? undefined : json);
+}
+
+function swaggerDocument() {
+ const paths = {
+ '/version': { get: {} },
+ '/': { get: {} },
+ };
+
+ for (const prefix of PREFIXES) paths[prefix] = { get: {} };
+ paths['/v1/buy/quote'] = { put: {} };
+
+ return {
+ openapi: '3.0.0',
+ info: {
+ title: 'DFX API',
+ version: 'local',
+ },
+ paths,
+ };
+}
+
+function fixtureForPath(path) {
+ for (const prefix of PREFIXES) {
+ if (path === prefix || path.startsWith(`${prefix}/`)) return fixtures[prefix];
+ }
+ return undefined;
+}
+
+function respondAfterDrain(req, res, body) {
+ req.on('data', () => {});
+ req.on('end', () => {
+ sendJson(res, body, req.method);
+ });
+}
+
+function createLocalBackend() {
+ return http.createServer((req, res) => {
+ const method = req.method;
+ const path = (req.url || '/').split('?')[0];
+
+ if (method === 'GET' && path === '/swagger-json') {
+ sendJson(res, swaggerDocument(), method);
+ return;
+ }
+
+ if ((method === 'GET' || method === 'HEAD') && path === '/') {
+ sendJson(res, { ok: true }, method);
+ return;
+ }
+
+ if (method === 'GET' || method === 'HEAD') {
+ const fixture = fixtureForPath(path);
+ if (fixture !== undefined) {
+ sendJson(res, fixture, method);
+ return;
+ }
+ }
+
+ if (method === 'PUT' && path === '/v1/buy/quote') {
+ respondAfterDrain(req, res, {
+ price: 1,
+ from: { amount: 1 },
+ to: { amount: 1 },
+ });
+ return;
+ }
+
+ const body = {
+ proxied: true,
+ method,
+ path,
+ };
+ if (method !== 'GET' && method !== 'HEAD') {
+ respondAfterDrain(req, res, body);
+ return;
+ }
+ sendJson(res, body, method);
+ });
+}
+
+if (require.main === module) {
+ const port = Number(orFallback(process.env.LOCAL_BACKEND_PORT, DEFAULT_PORT));
+ const localBackend = createLocalBackend();
+
+ localBackend.on('error', (error) => {
+ console.error(error);
+ process.exit(1);
+ });
+ localBackend.listen(port, DEFAULT_BIND);
+}
+
+module.exports = {
+ DEFAULT_BIND,
+ DEFAULT_PORT,
+ PREFIXES,
+ createLocalBackend,
+};
diff --git a/scripts/start-local.js b/scripts/start-local.js
new file mode 100644
index 0000000..83f43b3
--- /dev/null
+++ b/scripts/start-local.js
@@ -0,0 +1,76 @@
+'use strict';
+
+const DEFAULT_FRONT_PORT = '3000';
+const DEFAULT_FRONT_BIND = '127.0.0.1';
+const DEFAULT_BACKEND_PORT = 3004;
+
+function orFallback(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ return value;
+}
+
+function shouldStartLocalBackend(env) {
+ return env.BACKEND_URL === undefined || env.BACKEND_URL === null || env.BACKEND_URL === '';
+}
+
+function applyLocalDefaults(env) {
+ env.BIND = orFallback(env.BIND, DEFAULT_FRONT_BIND);
+ env.PORT = orFallback(env.PORT, DEFAULT_FRONT_PORT);
+ return env;
+}
+
+if (require.main === module) {
+ applyLocalDefaults(process.env);
+
+ let localBackend;
+
+ function startFrontApi() {
+ // Load production code only after BACKEND_URL is available.
+ const server = require('../server.js');
+
+ function shutdown() {
+ server.server.close(() => {
+ if (localBackend === undefined) {
+ process.exit(0);
+ return;
+ }
+ localBackend.close(() => process.exit(0));
+ });
+ }
+
+ process.on('SIGINT', shutdown);
+ process.on('SIGTERM', shutdown);
+
+ // Match the ordering used by the server.js main entry point.
+ if (process.env.FRONT_API_EXIT_AFTER_BOOT === '1') server.maybeExitAfterBoot();
+ server.boot();
+ }
+
+ if (!shouldStartLocalBackend(process.env)) {
+ startFrontApi();
+ } else {
+ const { createLocalBackend } = require('./local-backend.js');
+ const configuredPort = orFallback(process.env.LOCAL_BACKEND_PORT, DEFAULT_BACKEND_PORT);
+ const port = Number(configuredPort);
+ localBackend = createLocalBackend();
+
+ localBackend.on('error', (error) => {
+ console.error(error);
+ process.exit(1);
+ });
+ localBackend.listen(port, '127.0.0.1', () => {
+ const boundPort = localBackend.address().port;
+ process.env.BACKEND_URL = `http://127.0.0.1:${boundPort}`;
+ console.log(`local backend http://127.0.0.1:${boundPort}`);
+ startFrontApi();
+ });
+ }
+}
+
+module.exports = {
+ DEFAULT_FRONT_PORT,
+ DEFAULT_FRONT_BIND,
+ DEFAULT_BACKEND_PORT,
+ shouldStartLocalBackend,
+ applyLocalDefaults,
+};
diff --git a/test/local-start.test.js b/test/local-start.test.js
new file mode 100644
index 0000000..e69154d
--- /dev/null
+++ b/test/local-start.test.js
@@ -0,0 +1,184 @@
+'use strict';
+
+const http = require('http');
+
+function fail(msg) {
+ console.error('FAIL:', msg);
+ process.exit(1);
+}
+
+process.env.BACKEND_URL = 'http://127.0.0.1:9';
+
+const server = require('../server.js');
+const { PREFIXES, createLocalBackend } = require('../scripts/local-backend.js');
+const {
+ shouldStartLocalBackend,
+ applyLocalDefaults,
+} = require('../scripts/start-local.js');
+
+function assert(condition, message) {
+ if (!condition) fail(message);
+}
+
+function request(port, method, path, body) {
+ return new Promise((resolve, reject) => {
+ const startedAt = Date.now();
+ const req = http.request({
+ host: '127.0.0.1',
+ port,
+ method,
+ path,
+ headers: body === undefined ? {} : {
+ 'content-type': 'application/json',
+ 'content-length': Buffer.byteLength(body),
+ },
+ }, (res) => {
+ let responseBody = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ responseBody += chunk;
+ });
+ res.on('end', () => {
+ resolve({
+ statusCode: res.statusCode,
+ body: responseBody,
+ duration: Date.now() - startedAt,
+ });
+ });
+ });
+ req.on('error', reject);
+ if (body !== undefined) req.write(body);
+ req.end();
+ });
+}
+
+function close(serverToClose) {
+ return new Promise((resolve, reject) => {
+ serverToClose.close((error) => {
+ if (error !== undefined) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+}
+
+async function main() {
+ assert(
+ JSON.stringify(PREFIXES) === JSON.stringify(server.CACHE_PREFIXES),
+ 'PREFIXES must match server.CACHE_PREFIXES',
+ );
+
+ assert(shouldStartLocalBackend({}) === true, 'missing BACKEND_URL must start stub');
+ assert(shouldStartLocalBackend({ BACKEND_URL: undefined }) === true, 'undefined BACKEND_URL must start stub');
+ assert(shouldStartLocalBackend({ BACKEND_URL: null }) === true, 'null BACKEND_URL must start stub');
+ assert(shouldStartLocalBackend({ BACKEND_URL: '' }) === true, 'empty BACKEND_URL must start stub');
+ assert(
+ shouldStartLocalBackend({ BACKEND_URL: 'http://127.0.0.1:9' }) === false,
+ 'configured BACKEND_URL must skip stub',
+ );
+
+ const defaults = applyLocalDefaults({});
+ assert(defaults.PORT === '3000', 'default PORT must be 3000');
+ assert(defaults.BIND === '127.0.0.1', 'default BIND must be loopback');
+ assert(Object.keys(defaults).length === 2, 'local defaults must not add extra keys');
+
+ const configured = applyLocalDefaults({ PORT: '4100', BIND: '0.0.0.0' });
+ assert(configured.PORT === '4100', 'configured PORT must be preserved');
+ assert(configured.BIND === '0.0.0.0', 'configured BIND must be preserved');
+
+ const complete = {
+ PORT: '4200',
+ BIND: '127.0.0.2',
+ BACKEND_URL: 'http://127.0.0.1:9',
+ };
+ applyLocalDefaults(complete);
+ assert(complete.PORT === '4200', 'complete PORT must be preserved');
+ assert(complete.BIND === '127.0.0.2', 'complete BIND must be preserved');
+ assert(complete.BACKEND_URL === 'http://127.0.0.1:9', 'BACKEND_URL must be preserved');
+ assert(Object.keys(complete).length === 3, 'complete environment must not gain keys');
+
+ const nullEnv = applyLocalDefaults({ PORT: null, BIND: null });
+ assert(nullEnv.PORT === '3000', 'null PORT must fall back to 3000');
+ assert(nullEnv.BIND === '127.0.0.1', 'null BIND must fall back to loopback');
+
+ const emptyEnv = applyLocalDefaults({ PORT: '', BIND: '' });
+ assert(emptyEnv.PORT === '3000', 'empty PORT must fall back to 3000');
+ assert(emptyEnv.BIND === '127.0.0.1', 'empty BIND must fall back to loopback');
+
+ const localBackend = createLocalBackend();
+ await new Promise((resolve, reject) => {
+ localBackend.once('error', reject);
+ localBackend.listen(0, '127.0.0.1', resolve);
+ });
+
+ try {
+ const port = localBackend.address().port;
+ const asset = await request(port, 'GET', '/v1/asset');
+ const assetExtra = await request(port, 'GET', '/v1/asset/extra');
+ const swagger = await request(port, 'GET', '/swagger-json');
+ const quote = await request(port, 'PUT', '/v1/buy/quote', '{"amount":1}');
+ const country = await request(port, 'GET', '/v1/country');
+ const language = await request(port, 'GET', '/v1/language');
+
+ for (const response of [asset, assetExtra, swagger, quote, country, language]) {
+ assert(response.statusCode === 200, 'stub response status must be 200');
+ assert(response.duration <= 100, `stub response exceeded 100ms: ${response.duration}ms`);
+ try {
+ response.json = JSON.parse(response.body);
+ } catch (error) {
+ fail(`stub response must contain JSON: ${error.message}`);
+ }
+ }
+
+ assert(Array.isArray(asset.json), 'asset fixture must be an array');
+ assert(asset.json[0].name === 'BTC', 'asset fixture must contain BTC');
+ assert(Array.isArray(assetExtra.json), 'asset prefix fixture must be an array');
+ assert(assetExtra.json[0].name === 'BTC', 'asset prefix fixture must contain BTC');
+
+ assert(Array.isArray(country.json), 'country fixture must be an array');
+ const countryFixture = country.json[0];
+ assert(countryFixture.symbol === 'CH', 'country symbol must be CH');
+ assert(countryFixture.name === 'Switzerland', 'country name must be Switzerland');
+ for (const field of [
+ 'locationAllowed',
+ 'ibanAllowed',
+ 'kycAllowed',
+ 'kycOrganizationAllowed',
+ 'nationalityAllowed',
+ 'bankAllowed',
+ 'cardAllowed',
+ 'cryptoAllowed',
+ ]) {
+ assert(countryFixture[field] === true, `country ${field} must be true`);
+ }
+
+ assert(Array.isArray(language.json), 'language fixture must be an array');
+ assert(language.json[0].symbol === 'EN', 'language fixture symbol must be EN');
+
+ assert(quote.json.price === 1, 'quote price must be 1');
+ assert(quote.json.from.amount === 1, 'quote from amount must be 1');
+ assert(quote.json.to.amount === 1, 'quote to amount must be 1');
+
+ assert(swagger.json.info.title, 'swagger info.title must be present');
+ assert(swagger.json.paths['/v1/asset'] !== undefined, 'swagger must contain /v1/asset');
+ assert(swagger.json.paths['/v1/user'] === undefined, 'swagger must not contain /v1/user');
+ assert(swagger.json.paths['/version'] !== undefined, 'swagger must contain /version');
+ for (const prefix of PREFIXES) {
+ assert(swagger.json.paths[prefix] !== undefined, `swagger must contain ${prefix}`);
+ }
+ } finally {
+ await close(localBackend);
+ }
+
+ console.log('ok local-start');
+}
+
+main().catch((error) => {
+ if (error.stack !== undefined) {
+ fail(error.stack);
+ return;
+ }
+ fail(String(error.message));
+});
diff --git a/test/test-server.sh b/test/test-server.sh
index ae47ee7..e5dce51 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
+# local one-command start (stub + process) local_start
# Pin test + 100% coverage gate for production JS (c8).
#
# Arms:
+# local one-command start (stub + process) local_start
# swagger snapshot empty → 503 local body swagger_empty
# PUT /v1/buy/quote → 503 backend unavailable quote_proxy
# mock records forwarded method/path/body quote_forward
@@ -87,6 +89,20 @@ grep -q 'FRONT_API_EXIT_AFTER_BOOT=1' "$repo_root/test/run-main-coverage.sh" ||
grep -q 'coverage:report' "$pkg" || fail "coverage_100: coverage:report script missing"
grep -q -- '--check-coverage' "$pkg" || fail "coverage_100: check-coverage missing from package.json"
+grep -Fq '"start": "node scripts/start-local.js"' "$repo_root/package.json" || fail "local_start: package.json must define npm start"
+test -f "$repo_root/scripts/start-local.js" || fail "local_start: scripts/start-local.js missing"
+test -f "$repo_root/scripts/local-backend.js" || fail "local_start: scripts/local-backend.js missing"
+grep -Fq '"scripts/**"' "$repo_root/.c8rc.json" || fail "local_start: scripts must be excluded from production coverage"
+if grep -q 'server.js' "$repo_root/.c8rc.json"; then fail "coverage_all: production server.js must not be excluded"; fi
+if grep -q scripts "$repo_root/Dockerfile"; then fail "local_start: Dockerfile must not copy scripts/"; fi
+grep -Fq 'npm start' "$repo_root/README.md" || fail "local_start: README must document npm start"
+grep -Fq 'npm start' "$repo_root/CONTRIBUTING.md" || fail "local_start: CONTRIBUTING must document npm start"
+grep -Fq 'scripts/**' "$repo_root/CONTRIBUTING.md" || fail "local_start: CONTRIBUTING must document scripts coverage"
+if grep -Eq 'createLocalBackend|shouldStartLocalBackend|applyLocalDefaults' "$repo_root/server.js"; then
+ fail "local_start: stub helpers must not be added to production server.js"
+fi
+test -f "$repo_root/test/local-start.test.js" || fail "local_start: test/local-start.test.js missing"
+
cd "$repo_root"
if [ ! -d node_modules/c8 ]; then
npm ci
@@ -98,4 +114,6 @@ bash "$repo_root/test/run-main-coverage.sh" || fail "require.main coverage run f
npm run coverage:report || fail "coverage 100% gate failed"
+node "$repo_root/test/local-start.test.js" || fail "local_start: node test/local-start.test.js failed"
+
echo "ok front-api server.js"
From 8bfad346f362c5fa9c56b26458ab8a6f8247340e Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:01:51 +0200
Subject: [PATCH 09/15] 01a02b77 - Serve known routes in 100ms; forward the
rest (#9)
* Stop forwarding client requests to the backend
A live wait on BACKEND_URL cannot guarantee 100ms. Uncached, unknown,
and quote requests now return 503 not served immediately. The GET cache
fills only off the request path. WebSocket upgrades are refused.
* Drop periodic swagger snapshot console.log
CONTRIBUTING allows console.log only as a boot log. refreshSwagger
also runs on a timer, so the success line is production-path output.
* Use nullish coalescing for request URL fallbacks
CONTRIBUTING requires ?? rather than || for value fallbacks. Empty
query-stripped paths still default to / when url is nullish.
* Forward unknown routes; keep known GETs local and 100ms
Known routes stay local (cache, swagger, optional Postgres) and must
finish in 100ms with no live backend wait. Everything else, including
quotes and WebSocket upgrades, is forwarded with no 100ms rule.
* Pair proxy and upgrade sockets on client close
Destroy the outbound request when the client finishes, closes, or
aborts. Tear down both sides of an upgrade tunnel on close. Pin the
known-vs-unknown split in the test-server arms list.
* Cap the test helper at 100ms only for known routes
Unknown forwards have no 100ms rule. The suite helper now takes an
optional maxMs so quote and other forwarded round-trips are not
rejected for taking longer than 100ms.
* Clarify auth forwarding and pin DB-null as not served
Authenticated cache-prefix GETs stay forwarded. Version and swagger
stay local even with Authorization. A null DB read must 503 not served,
not a truthy status.
* Forward parameterized GETs instead of answering 503
Known local GETs are exact list roots plus concrete swagger paths.
GET /v1/asset/1 is unknown and is forwarded so the rest of the API
still works.
* Catalog exact served paths; drop prefix subpath claims
Offered routes for cache list roots are exact. Parameterized swagger
templates are not served here. GET /v1/setting/infoBanner is catalogued
as its own concrete path.
* Keep known GETs to exact list roots
Nested paths such as /v1/setting/infoBanner and /v1/asset/1 are
unknown and forwarded. Cache refresh only fills / and the list roots.
The catalog matches that exact set.
---
CONTRIBUTING.md | 55 +++--
README.md | 21 +-
REVIEW.md | 33 ++-
offered-routes.json | 53 +++--
server.js | 144 ++++++------
test/offered-routes.test.js | 8 +-
test/server.test.js | 421 +++++++++++++++++-------------------
test/test-server.sh | 36 ++-
8 files changed, 390 insertions(+), 381 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4523f1f..2b142a5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -127,7 +127,7 @@ When applicable, every pull request must include:
**not** a grant to change that path. Private repositories are not named.
5. **A note in the PR body** when the outward behaviour of this layer changes
(cache, 503 bodies, `x-front-api`, which paths are answered here versus
- proxied, quote source). Do not name private repositories. Public consumer
+ forwarded, quote source). Do not name private repositories. Public consumer
paths belong in `offered-routes.json`.
Missing any applicable item = changes requested.
@@ -150,23 +150,34 @@ Missing any applicable item = changes requested.
## This process
-- This process answers a **fixed** set of routes itself. Every other request is
- forwarded to `BACKEND_URL` without this repository listing those routes.
+- This process answers a **fixed** set of routes itself from local state
+ (version, swagger snapshot, fresh GET cache, optional Postgres). Those
+ **known** routes must finish within **100ms**. It is **forbidden** to
+ satisfy them by waiting on `BACKEND_URL` or any other system that cannot
+ guarantee 100ms. A cache miss on a known GET is `503` `not served`
+ immediately — never a live backend fetch on that request.
+- Every other request (routes this process does **not** know) is forwarded
+ to `BACKEND_URL`. Forwarded requests have **no** 100ms rule. Quotes and
+ WebSocket upgrades are unknown here and are forwarded.
+- The backend is contacted on the request path only for unknown routes.
+ Swagger snapshot and GET-cache refresh stay **off** the request path and
+ exist only to serve known GETs from local state.
- The swagger snapshot is an **allowlist** of paths this process serves, not a
denylist.
-- Authenticated requests are never answered from the GET cache.
-- Quotes are reverse-proxied to `BACKEND_URL`. This process does not keep a quote book.
-- A down backend always returns 503. Never serve an expired cache body.
-- Every HTTP response from this process must complete within **100ms**. That
- bound is technical and always enforced, not a target. The process must cut
- the request so the client never waits longer (`503` `response deadline
- exceeded`) and must emit an `ERROR` log. It is **forbidden** to add code
- that cannot finish in that budget: unbounded awaits, blocking work, uncapped
- outbound waits, sleeps, or any other path that would let a ping exceed
- 100ms. The client-facing deadline is always 100ms, including the WebSocket
- upgrade handshake until a completed `101`. A WebSocket after that handshake
- is no longer an HTTP response. `REQUEST_TIMEOUT_MS` may only lower the
- outbound wait to the backend, never raise it above 100ms.
+- Authenticated requests are never answered from the GET cache; those
+ cache-prefix GETs are unknown here and are forwarded. `GET /version`
+ and swagger remain local even with `Authorization`.
+- Never serve an expired cache body.
+- Every **known** HTTP response from this process must complete within
+ **100ms**. That bound is technical and always enforced, not a target. The
+ process must cut a known request so the client never waits longer (`503`
+ `response deadline exceeded`) and must emit an `ERROR` log. It is
+ **forbidden** to add code on a known route that cannot finish in that
+ budget: forwarding to the backend, unbounded awaits, blocking work,
+ uncapped outbound waits, sleeps, or any other path that would let a ping
+ of a known route exceed 100ms. `REQUEST_TIMEOUT_MS` may only lower
+ background outbound waits for cache/swagger refresh, never raise them
+ above 100ms. Do not attach that budget to forwarded unknown requests.
- Do not expose internals in responses (SQL credentials, backend hosts, or
other secrets).
@@ -197,11 +208,13 @@ if CI is green.
There is no production JavaScript in this repository that may ship below 100%
coverage. The coverage gate is the CI job, not a review courtesy.
-There is no HTTP response this process may take longer than 100ms to finish.
-`test/test-server.sh` pins `MAX_RESPONSE_MS = 100`, the inbound deadline, the
-upgrade-handshake budget, the `ERROR` log, and the outbound cap; the Node
-suite rejects any helper round-trip over 100ms. A miss is a red `test` job,
-not a review note.
+There is no **known** HTTP response this process may take longer than 100ms
+to finish. Unknown requests are forwarded and are not in that budget.
+`test/test-server.sh` pins `MAX_RESPONSE_MS = 100`, the inbound deadline on
+known routes, that known routes are not forwarded, that unknown routes are
+forwarded, the `ERROR` log, and the background outbound cap. The Node suite
+rejects any **known-route** helper round-trip over 100ms. A miss is a red
+`test` job, not a review note.
Every path this process answers itself also needs **frontend E2E** coverage:
a real UI flow that hits that function, listed in `offered-routes.json`.
diff --git a/README.md b/README.md
index ba85293..ba8c1fc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# front-api
-Public HTTP layer in front of the DFX backend. This process answers a fixed set of routes itself (`/version`, a filtered swagger snapshot, GET cache, optional Postgres reads for country/language). Every other request is forwarded to `BACKEND_URL` without this repository listing those routes.
+Public HTTP layer in front of the DFX backend. Known routes (`/version`, a filtered swagger snapshot, GET cache, optional Postgres reads for country/language) are answered locally within 100ms and never wait on `BACKEND_URL`. Everything else is forwarded.
## Run
@@ -38,17 +38,22 @@ Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `C
- `GET /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`)
- `GET /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json` — filtered swagger snapshot from the backend; empty snapshot returns 503
-- GET/HEAD cache (default 5 minutes) for `/` and the public list prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
+- GET cache (default 5 minutes) for `/` and the public list roots `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`). Nested paths (for example `/v1/setting/infoBanner` or `/v1/asset/1`) are unknown here and are forwarded. HEAD is forwarded.
- Optional Postgres reads for `GET /v1/country` and `GET /v1/language` when `SQL_HOST` is set
-Only fresh cache hits are served. After the TTL the next request fetches again. If that fetch cannot reach the backend, the response is 503 — never an expired cache body. A still-fresh cache hit is served without calling the backend.
+Only fresh cache hits are served for known GETs. The cache is filled in the
+background, not during a client request. After the TTL the next known GET
+is `503` `not served` until a background refresh succeeds — never an
+expired cache body, never a live backend wait on that request.
-Everything else, including quotes, is reverse-proxied to `BACKEND_URL`. WebSocket upgrades are tunnelled the same way.
+Everything this process does not know (quotes, authenticated calls, other
+methods and paths, WebSocket upgrades) is forwarded to `BACKEND_URL` with
+no 100ms rule.
-Every HTTP response must finish within 100ms, including the WebSocket
-upgrade handshake. A slower response is a hard bug: the process answers
-`503` `response deadline exceeded` (or cuts the upgrade socket), emits an
-`ERROR` log, and CI fails. Code that cannot meet that bound is forbidden.
+Every **known** HTTP response must finish within 100ms. Forwarding a known
+route is forbidden because that cannot guarantee 100ms. A slower known
+response is a hard bug: the process answers `503` `response deadline
+exceeded`, emits an `ERROR` log, and CI fails.
## Images
diff --git a/REVIEW.md b/REVIEW.md
index 4d48399..5f0fee7 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -18,8 +18,8 @@ This item includes the EN/DE PR-body form and GitHub-verified commits.
Job `test` is `success` on **exactly this** SHA. That job includes the 100%
coverage gate (`c8 --check-coverage` on all four metrics), the offered-route
catalog check (`test/test-offered-routes.sh`), and the 100ms response
-deadline. A coverage miss, catalog miss, or a helper round-trip over 100ms
-is a red job, not a review note.
+deadline on known routes. A coverage miss, catalog miss, or a known-route
+helper round-trip over 100ms is a red job, not a review note.
- `skipped` does not count as green unless this repository documents that skip
as expected. Today: `test` is not skipped on drafts.
@@ -51,7 +51,7 @@ repository hygiene rule in CONTRIBUTING.md.
## 6. Tests cover the change
New or changed branches in `server.js` (503 vs 200, cache hit/miss, allowlist,
-timeout, 100ms deadline, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
+timeout, 100ms deadline on known routes, unknown forwarding, poller gate) have a pin in `test/test-server.sh`. Workflow-gate
changes have a pin in `test/test-main-from-develop.sh`. Automatic release-PR
body-form changes have a pin in `test/test-auto-release-pr.sh`. Green CI
without a pin for a behaviour change is fail. Every production `*.js` file
@@ -115,11 +115,22 @@ Any fail on this item keeps the pull request as a draft or on changes
requested. There is no "follow-up E2E" for a new or changed offered
function unless the reviewer grants that in writing.
-## 12. 100ms response deadline
-
-Every HTTP response from this process must finish within 100ms. Fail if
-`MAX_RESPONSE_MS` is not 100, if `REQUEST_TIMEOUT_MS` can exceed 100, if
-the inbound budget is missing, if the upgrade handshake has no deadline,
-if a deadline miss does not emit an `ERROR` log, if the change adds a
-path that cannot finish in 100ms, or if a test round-trip is allowed to
-take longer. A slower ping is a hard bug, not a performance note.
+## 12. Known routes: 100ms. Unknown routes: forwarded
+
+This process knows a fixed set of local GET routes (version, swagger
+snapshot, fresh GET cache, optional Postgres). Every **known** HTTP
+response must finish within 100ms. Fail if `MAX_RESPONSE_MS` is not 100,
+if `REQUEST_TIMEOUT_MS` can exceed 100 for background refresh, if the
+inbound budget is missing on a known route, if a known route is forwarded
+to the backend or waits on any system that cannot guarantee 100ms, if a
+deadline miss on a known route does not emit an `ERROR` log, if the change
+adds a known path that cannot finish in 100ms, or if a **known-route**
+test round-trip is allowed to take longer. Forwarding a known route is a
+hard fail: it cannot guarantee 100ms. A slower ping of a known route is a
+hard bug, not a performance note.
+
+Unknown routes (everything this process does not answer itself, including
+quotes and WebSocket upgrades) **must** be forwarded to `BACKEND_URL`.
+They have no 100ms rule. Fail if an unknown request is answered with
+`503` `not served` instead of being forwarded, or if the 100ms budget is
+attached to the forward path.
diff --git a/offered-routes.json b/offered-routes.json
index c2a2c2b..11ed2e2 100644
--- a/offered-routes.json
+++ b/offered-routes.json
@@ -1,6 +1,6 @@
{
"title": "Offered routes: usage and frontend E2E",
- "rules": "Every path this process answers itself has a row. usedIn is a public consumer repo+file, or unidentified:true with a note when no public call site is named. e2e is a frontend-inclusive E2E in a public repo (any branch), or unidentified:true when none is named. This repo's CI checks fields only and does not run foreign suites. Private repositories are not named. Proxied backend routes are not listed.",
+ "rules": "Every path this process answers itself has a row. usedIn is a public consumer repo+file, or unidentified:true with a note when no public call site is named. e2e is a frontend-inclusive E2E in a public repo (any branch), or unidentified:true when none is named. This repo's CI checks fields only and does not run foreign suites. Private repositories are not named. Backend routes this process does not serve are not listed.",
"routes": [
{
"method": "GET",
@@ -9,7 +9,7 @@
"usedIn": [
{
"unidentified": true,
- "note": "isServedPath and isCacheable: unauthenticated GET / may be answered from the GET cache, otherwise proxied. No dedicated public frontend call site named."
+ "note": "isServedPath and isCacheable: unauthenticated GET / is answered from the GET cache after a background refresh. A miss is 503 not served — never forwarded. No dedicated public frontend call site named."
}
],
"e2e": [
@@ -82,12 +82,12 @@
{
"method": "GET",
"path": "/v1/asset",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/asset.hook.ts",
- "note": "GET AssetUrl.get (asset)."
+ "note": "GET AssetUrl.get (asset). The list root is served from the background GET cache; a miss is 503 not served. Parameterized subpaths such as /v1/asset/1 are forwarded."
},
{
"repo": "DFXswiss/services",
@@ -106,12 +106,12 @@
{
"method": "GET",
"path": "/v1/fiat",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/fiat.hook.ts",
- "note": "GET FiatUrl.get (fiat)."
+ "note": "GET FiatUrl.get (fiat). Served from the background GET cache; a miss is 503 not served, never forwarded."
},
{
"repo": "RealUnitCH/app",
@@ -130,12 +130,12 @@
{
"method": "GET",
"path": "/v1/country",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/country.hook.ts",
- "note": "GET CountryUrl.get (country)."
+ "note": "GET CountryUrl.get (country). Served from the background GET cache or optional Postgres; a miss is 503 not served, never forwarded."
},
{
"repo": "RealUnitCH/app",
@@ -154,12 +154,12 @@
{
"method": "GET",
"path": "/v1/language",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/language.hook.ts",
- "note": "GET LanguageUrl.get (language)."
+ "note": "GET LanguageUrl.get (language). Served from the background GET cache or optional Postgres; a miss is 503 not served, never forwarded."
},
{
"repo": "RealUnitCH/app",
@@ -178,87 +178,86 @@
{
"method": "GET",
"path": "/v1/statistic",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"unidentified": true,
- "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that hits /v1/statistic live. Required before changing this prefix."
+ "note": "No frontend E2E named that hits /v1/statistic live. Required before changing this list root."
}
]
},
{
"method": "GET",
"path": "/v1/coin",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"unidentified": true,
- "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that hits /v1/coin live. Required before changing this prefix."
+ "note": "No frontend E2E named that hits /v1/coin live. Required before changing this list root."
}
]
},
{
"method": "GET",
"path": "/v1/setting",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
- "repo": "DFXswiss/packages",
- "path": "packages/react/src/hooks/settings.hook.ts",
- "note": "GET SettingsUrl.infoBanner (setting/infoBanner)."
+ "unidentified": true,
+ "note": "List root GET /v1/setting is served from the background cache. Nested GET /v1/setting/infoBanner is forwarded, not answered here."
}
],
"e2e": [
{
"unidentified": true,
- "note": "Widget specs intercept GET /v1/setting/infoBanner; that is not E2E of this layer. A live frontend E2E is required before changing this prefix."
+ "note": "No frontend E2E named that GETs the list root /v1/setting live. Nested infoBanner is forwarded."
}
]
},
{
"method": "GET",
"path": "/v1/bank",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/bank.hook.ts",
- "note": "GET BankUrl.get (bank)."
+ "note": "GET BankUrl.get (bank). Served from the background GET cache; a miss is 503 not served, never forwarded."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that GETs /v1/bank live through the UI. Required before changing this prefix."
+ "note": "No frontend E2E named that GETs /v1/bank live through the UI. Required before changing this list root."
}
]
},
{
"method": "GET",
"path": "/v1/app",
- "match": "prefix",
+ "match": "exact",
"usedIn": [
{
"unidentified": true,
- "note": "Public GET cache prefix on the swagger allowlist. No named call site in DFXswiss/packages."
+ "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that hits /v1/app live. Required before changing this prefix."
+ "note": "No frontend E2E named that hits /v1/app live. Required before changing this list root."
}
]
}
diff --git a/server.js b/server.js
index e0afb8c..60772bb 100644
--- a/server.js
+++ b/server.js
@@ -31,8 +31,8 @@ function outboundTimeoutMs(raw) {
const REQUEST_TIMEOUT_MS = outboundTimeoutMs(orFallback(process.env.REQUEST_TIMEOUT_MS, MAX_RESPONSE_MS));
const STARTED = new Date().toISOString();
-// Public GET prefixes this layer may answer from cache. Authenticated
-// requests are never cached — they always go to the backend.
+// Public GET list roots this layer may answer from cache. Authenticated
+// requests are never cached.
const CACHE_PREFIXES = [
'/v1/asset',
'/v1/fiat',
@@ -77,15 +77,15 @@ try {
}
function cacheKey(req) {
- return req.method + ' ' + req.url;
+ return req.method + ' ' + (req.url ?? '/').split('?')[0];
}
function isCacheable(req) {
- if (req.method !== 'GET' && req.method !== 'HEAD') return false;
+ if (req.method !== 'GET') return false;
if (req.headers.authorization) return false;
- const path = (req.url || '/').split('?')[0];
+ const path = (req.url ?? '/').split('?')[0];
if (path === '/' || path === '/version' || path === '/swagger' || path === '/swagger-json') return true;
- return CACHE_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
+ return CACHE_PREFIXES.includes(path);
}
function getCached(key) {
@@ -116,12 +116,6 @@ function logDeadlineError(req) {
console.error('ERROR response exceeded ' + MAX_RESPONSE_MS + 'ms', req.method, req.url);
}
-function isUpgradeHandshakeComplete(headerBlock) {
- if (headerBlock.indexOf('\r\n\r\n') < 0) return false;
- const statusLine = headerBlock.slice(0, headerBlock.indexOf('\r\n'));
- return statusLine.split(' ')[1] === '101';
-}
-
function attachResponseBudget(req, res, budgetMs) {
const asked = budgetMs === undefined ? MAX_RESPONSE_MS : budgetMs;
const limit = Math.min(MAX_RESPONSE_MS, asked);
@@ -156,46 +150,6 @@ function attachResponseBudget(req, res, budgetMs) {
return true;
}
-function attachUpgradeBudget(req, socket, up, budgetMs) {
- if (socket.destroyed) {
- if (!up.destroyed) up.destroy();
- return true;
- }
- const asked = budgetMs === undefined ? MAX_RESPONSE_MS : budgetMs;
- const limit = Math.min(MAX_RESPONSE_MS, asked);
- const fireAt = Math.max(1, limit - 10);
- let settled = false;
- let header = '';
- const finish = () => {
- if (settled) return;
- settled = true;
- up.removeListener('data', onData);
- header = '';
- };
- const onData = (chunk) => {
- header += chunk.toString('latin1');
- if (isUpgradeHandshakeComplete(header)) finish();
- };
- const timer = setTimeout(() => {
- if (settled) return;
- logDeadlineError(req);
- if (!up.destroyed) up.destroy();
- if (!socket.destroyed) socket.destroy();
- finish();
- }, fireAt);
- timer.unref();
- up.on('data', onData);
- socket.once('close', () => {
- if (!up.destroyed) up.destroy();
- finish();
- });
- up.once('close', () => {
- if (!socket.destroyed) socket.destroy();
- finish();
- });
- return true;
-}
-
function onPoolConnect(client) {
return client.query('SET statement_timeout TO 90');
}
@@ -255,9 +209,26 @@ const EXACT_GET_PATHS = [
];
function isServedPath(path) {
- const p = (path || '/').split('?')[0];
+ const p = (path ?? '/').split('?')[0];
if (EXACT_GET_PATHS.includes(p)) return true;
- return CACHE_PREFIXES.some((pref) => p === pref || p.startsWith(pref + '/'));
+ return CACHE_PREFIXES.includes(p);
+}
+
+function isKnownLocalRequest(req) {
+ if (req.method !== 'GET') return false;
+ const path = (req.url ?? '/').split('?')[0];
+ if (
+ path === '/version' ||
+ path === '/swagger' ||
+ path === '/swagger/' ||
+ path === '/swagger-ui' ||
+ path === '/swagger-ui/' ||
+ path === '/swagger-json' ||
+ path === '/swagger-json/'
+ ) {
+ return true;
+ }
+ return isCacheable(req);
}
async function refreshSwagger() {
@@ -270,7 +241,6 @@ async function refreshSwagger() {
paths[p] = ops;
}
swaggerSpec = { ...got.json, paths, info: { ...(got.json.info || {}), title: 'DFX API' } };
- console.log('swagger snapshot paths', Object.keys(paths).length);
} catch (err) {
console.error('swagger refresh', err.message);
}
@@ -308,7 +278,7 @@ function sendJson(res, status, body, via, extraHeaders) {
'x-content-type-options': 'nosniff',
'x-front-api': via,
'access-control-allow-origin': '*',
- }, extraHeaders || {}));
+ }, extraHeaders ?? {}));
res.end(buf);
}
@@ -395,13 +365,20 @@ async function tryDbRead(path) {
return Buffer.from(JSON.stringify(spec.map(result.rows)));
}
+function rejectUnserved(res) {
+ sendJson(res, 503, { statusCode: 503, message: 'not served', retryAfter: 1 }, 'local', {
+ connection: 'close',
+ 'retry-after': '1',
+ });
+}
+
function proxy(req, res) {
if (!canWrite(res)) return;
const target = new URL(BACKEND);
const opts = {
hostname: target.hostname,
port: backendPortFor(target),
- path: req.url,
+ path: req.url ?? '/',
method: req.method,
headers: { ...req.headers, host: target.host },
};
@@ -413,10 +390,6 @@ function proxy(req, res) {
const body = Buffer.concat(chunks);
const headers = { ...up.headers };
delete headers['transfer-encoding'];
- if (isCacheable(req) && up.statusCode === 200) {
- putCache(cacheKey(req), up.statusCode, headers, body);
- headers['x-front-api'] = 'miss';
- }
res.writeHead(up.statusCode, headers);
res.end(body);
});
@@ -431,16 +404,35 @@ function proxy(req, res) {
});
res.end(JSON.stringify({ statusCode: 503, message: 'backend-api unavailable', retryAfter: 30 }));
});
- attachRequestTimeout(p, REQUEST_TIMEOUT_MS, () => {
- p.destroy();
- });
res.on('finish', () => p.destroy());
+ res.on('close', () => p.destroy());
+ req.on('aborted', () => p.destroy());
req.pipe(p);
}
+function cacheRefreshPaths() {
+ return ['/', ...CACHE_PREFIXES];
+}
+
+async function refreshCache() {
+ for (const p of cacheRefreshPaths()) {
+ try {
+ const got = await getBackendJson(p);
+ if (got.status !== 200) continue;
+ putCache('GET ' + p, 200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, Buffer.from(JSON.stringify(got.json)));
+ } catch (err) {
+ console.error('cache refresh', p, err.message);
+ }
+ }
+}
+
const server = http.createServer((req, res) => {
+ if (!isKnownLocalRequest(req)) {
+ proxy(req, res);
+ return;
+ }
attachResponseBudget(req, res);
- const path = (req.url || '/').split('?')[0];
+ const path = (req.url ?? '/').split('?')[0];
if (path === '/version' && req.method === 'GET') {
sendVersion(req, res, localVersion(), 'local');
return;
@@ -468,7 +460,7 @@ const server = http.createServer((req, res) => {
}
const key = cacheKey(req);
- const hit = isCacheable(req) ? getCached(key) : null;
+ const hit = getCached(key);
if (hit && Date.now() <= hit.exp) {
if (!canWrite(res)) return;
const headers = { ...hit.headers, 'x-front-api': 'hit' };
@@ -481,7 +473,7 @@ const server = http.createServer((req, res) => {
tryDbRead(path)
.then((body) => {
if (!body) {
- proxy(req, res);
+ rejectUnserved(res);
return;
}
putCache(key, 200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, body);
@@ -489,12 +481,12 @@ const server = http.createServer((req, res) => {
})
.catch((err) => {
console.error('db-read', path, err.message);
- proxy(req, res);
+ rejectUnserved(res);
});
return;
}
- proxy(req, res);
+ rejectUnserved(res);
});
server.on('upgrade', (req, socket, head) => {
@@ -520,16 +512,18 @@ server.on('upgrade', (req, socket, head) => {
up.pipe(socket);
socket.pipe(up);
});
- attachUpgradeBudget(req, socket, up);
up.on('error', () => socket.destroy());
socket.on('error', () => up.destroy());
+ socket.once('close', () => up.destroy());
+ up.once('close', () => socket.destroy());
});
function boot() {
server.listen(PORT, BIND, () => {
console.log(`front-api listening on ${BIND}:${PORT}` + (pool ? ' db-read on' : ''));
- refreshSwagger();
+ refreshSwagger().then(() => refreshCache());
setInterval(refreshSwagger, 10 * 60 * 1000).unref();
+ setInterval(refreshCache, 60 * 1000).unref();
});
}
@@ -571,9 +565,14 @@ module.exports = {
EXACT_GET_PATHS,
cache,
isServedPath,
+ isKnownLocalRequest,
isCacheable,
cacheKey,
refreshSwagger,
+ refreshCache,
+ cacheRefreshPaths,
+ rejectUnserved,
+ proxy,
swaggerHtml,
countryDto,
languageDto,
@@ -584,15 +583,12 @@ module.exports = {
localVersion,
sendJson,
sendVersion,
- proxy,
attachRequestTimeout,
attachResponseBudget,
- attachUpgradeBudget,
attachPoolGuards,
onPoolConnect,
canWrite,
logDeadlineError,
- isUpgradeHandshakeComplete,
MAX_RESPONSE_MS,
outboundTimeoutMs,
setSwaggerSpec,
diff --git a/test/offered-routes.test.js b/test/offered-routes.test.js
index ff77ed2..4647cf2 100644
--- a/test/offered-routes.test.js
+++ b/test/offered-routes.test.js
@@ -86,9 +86,9 @@ for (const p of EXACT_GET_PATHS) {
if (!exactGetNames.has(p)) fail('served GET path missing from exact catalog names: ' + p);
}
for (const p of CACHE_PREFIXES) {
- const row = rowFor('GET', p, 'prefix');
- if (!row) fail('CACHE_PREFIX missing as prefix row: ' + p);
- if (!catalogCovers('GET', p + '/x')) fail('CACHE_PREFIX subpath missing from catalog: ' + p + '/x');
+ const row = rowFor('GET', p, 'exact');
+ if (!row) fail('CACHE_PREFIX missing as exact row: ' + p);
+ if (catalogCovers('GET', p + '/x')) fail('CACHE_PREFIX subpath must not be catalogued as served: ' + p + '/x');
}
const expectedKeys = new Set();
@@ -99,6 +99,6 @@ for (const key of seen) {
}
if (isServedPath('/v1/user')) fail('isServedPath unexpectedly true for /v1/user');
-if (catalogCovers('GET', '/v1/user')) fail('proxied /v1/user must not be in the catalog');
+if (catalogCovers('GET', '/v1/user')) fail('unserved /v1/user must not be in the catalog');
console.log('ok offered-routes.json', catalog.routes.length, 'rows');
diff --git a/test/server.test.js b/test/server.test.js
index a261e92..ae42b0a 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -3,8 +3,8 @@
const http = require('http');
const net = require('net');
const path = require('path');
-const { EventEmitter } = require('events');
const { Readable } = require('stream');
+const { EventEmitter } = require('events');
const { spawn, spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..');
@@ -36,9 +36,10 @@ function close(srv) {
});
}
-function request(port, method, urlPath, body, headers) {
+function request(port, method, urlPath, body, headers, maxMs) {
return new Promise((resolve, reject) => {
const t0 = Date.now();
+ const limit = maxMs === undefined ? 100 : maxMs;
const payload =
body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
const req = http.request(
@@ -57,7 +58,7 @@ function request(port, method, urlPath, body, headers) {
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const ms = Date.now() - t0;
- if (ms > 100) {
+ if (limit > 0 && ms > limit) {
reject(new Error('slow ' + method + ' ' + urlPath + ' ' + ms + 'ms'));
return;
}
@@ -164,7 +165,14 @@ async function main() {
const quote = { rate: 2, fees: { rate: 0.01, fixed: 0 } };
const ram = { price: 1 };
const swagger = {
- paths: { '/v1/asset': { get: {} }, '/v1/user': { get: {} }, '/version': { get: {} } },
+ paths: {
+ '/v1/asset': { get: {} },
+ '/v1/user': { get: {} },
+ '/version': { get: {} },
+ '/v1/setting/infoBanner': { get: {} },
+ '/v1/asset/{id}': { get: {} },
+ '/v1/bank': { post: {} },
+ },
};
const seen = [];
@@ -183,12 +191,18 @@ async function main() {
'/v1/realunit/brokerbot/buyShares': ram,
'/v1/realunit/brokerbot/info': ram,
'/v1/realunit/brokerbot/price': ram,
+ '/': { root: 1 },
'/swagger-json': swagger,
'/v1/statistic': { ok: 1 },
'/v1/setting': { ok: 1 },
+ '/v1/setting/infoBanner': { banner: 1 },
'/v1/bank': { ok: 1 },
- '/v1/app': { ok: 1 },
+ '/v1/app': (req, res) => {
+ res.writeHead(500, { 'content-type': 'application/json' });
+ res.end('{"ok":false}');
+ },
'/v1/coin': { ok: 1 },
+ '/v1/user': { user: 1 },
}, seen),
);
const bPort = await listen(backend);
@@ -207,6 +221,7 @@ async function main() {
CACHE_PREFIXES,
cache,
isServedPath,
+ isKnownLocalRequest,
isCacheable,
cacheKey,
refreshSwagger,
@@ -222,18 +237,19 @@ async function main() {
sendVersion,
attachRequestTimeout,
attachResponseBudget,
- attachUpgradeBudget,
attachPoolGuards,
+ refreshCache,
+ cacheRefreshPaths,
+ rejectUnserved,
+ proxy,
onPoolConnect,
canWrite,
- isUpgradeHandshakeComplete,
MAX_RESPONSE_MS,
outboundTimeoutMs,
setSwaggerSpec,
getSwaggerSpec,
setPool,
getPool,
- proxy,
boot,
maybeExitAfterBoot,
orFallback,
@@ -256,10 +272,7 @@ async function main() {
fail('outboundTimeoutMs invalid');
}
if (outboundTimeoutMs(50) !== 50 || outboundTimeoutMs(20000) !== 100) fail('outboundTimeoutMs cap');
- if (!isUpgradeHandshakeComplete('HTTP/1.1 101 Switching Protocols\r\n\r\n')) fail('handshake 101');
- if (isUpgradeHandshakeComplete('HTTP/1.1 101\r\n')) fail('handshake incomplete');
- if (isUpgradeHandshakeComplete('HTTP/1.1 400 Bad Request\r\n\r\n')) fail('handshake 400');
- if (!isUpgradeHandshakeComplete('HTTP/1.1 101\r\n\r\n')) fail('handshake 101 end');
+
if (orFallback('', 'x') !== 'x' || orFallback('a', 'x') !== 'a') fail('orFallback');
if (orFallback(undefined, 'x') !== 'x' || orFallback(null, 'x') !== 'x') fail('orFallback nullish');
const { URL } = require('url');
@@ -275,12 +288,22 @@ async function main() {
if (isServedPath('/v1/buy/quote') || isServedPath('/v1/sell/quote') || isServedPath('/v1/swap/quote')) {
fail('isServedPath quotes');
}
- if (!isServedPath('/v1/asset/1') || !isServedPath(undefined)) fail('isServedPath');
+ if (!isServedPath('/v1/asset') || !isServedPath(undefined)) fail('isServedPath');
+ if (isServedPath('/v1/asset/{id}')) fail('isServedPath template');
if (isServedPath('/v1/realunit/quote/price')) fail('isServedPath ram');
if (isServedPath('/v1/user')) fail('isServedPath user');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/v1/asset', headers: {} })) fail('known GET asset');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/version', headers: {} })) fail('known version');
+ if (isKnownLocalRequest({ method: 'PUT', url: '/v1/buy/quote', headers: {} })) fail('unknown quote');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/user', headers: {} })) fail('unknown user');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('unknown auth GET');
+ if (isKnownLocalRequest({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('unknown HEAD');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('unknown asset id');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/swagger-json', headers: { authorization: 'x' } })) fail('known swagger ignores auth');
if (!isCacheable({ method: 'GET', url: '/v1/asset', headers: {} })) fail('cache GET');
- if (!isCacheable({ method: 'HEAD', url: '/', headers: {} })) fail('cache HEAD');
+ if (isCacheable({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('cache nested id');
+ if (isCacheable({ method: 'HEAD', url: '/', headers: {} })) fail('cache HEAD');
if (!isCacheable({ method: 'GET', url: '/version', headers: {} })) fail('cache version');
if (!isCacheable({ method: 'GET', url: '/swagger', headers: {} })) fail('cache swagger');
if (!isCacheable({ method: 'GET', url: '/swagger-json', headers: {} })) fail('cache swagger-json');
@@ -288,6 +311,8 @@ async function main() {
if (isCacheable({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('cache auth');
if (isCacheable({ method: 'GET', url: '/v1/user', headers: {} })) fail('cache user');
if (cacheKey({ method: 'GET', url: '/a' }) !== 'GET /a') fail('cacheKey');
+ if (cacheKey({ method: 'GET', url: '/v1/asset?x=1' }) !== 'GET /v1/asset') fail('cacheKey query');
+ if (cacheKey({ method: 'GET', url: undefined }) !== 'GET /') fail('cacheKey empty');
if (swaggerHtml().indexOf('swagger-ui') < 0) fail('swaggerHtml');
if (localVersion().commit !== 'front-api') fail('localVersion');
@@ -358,9 +383,18 @@ async function main() {
setPool(null);
await refreshSwagger();
+ if (isCacheable({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('nested swagger GET is unknown');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('infoBanner is forwarded');
if (!getSwaggerSpec() || !getSwaggerSpec().paths['/v1/asset'] || getSwaggerSpec().paths['/v1/user']) {
fail('refreshSwagger allowlist');
}
+ if (getSwaggerSpec().paths['/v1/setting/infoBanner'] || getSwaggerSpec().paths['/v1/asset/{id}']) {
+ fail('refreshSwagger must drop nested and templates');
+ }
+ const refreshPaths = cacheRefreshPaths();
+ if (!refreshPaths.includes('/') || !refreshPaths.includes('/v1/asset')) fail('cacheRefreshPaths roots');
+ if (refreshPaths.includes('/v1/setting/infoBanner')) fail('cacheRefreshPaths nested');
+ if (refreshPaths.includes('/version')) fail('cacheRefreshPaths local version');
const port = await listen(server);
try {
@@ -377,49 +411,95 @@ async function main() {
server.emit('request', mkReq('/swagger'), blocked);
putCache('GET /v1/asset', 200, { 'content-type': 'application/json' }, Buffer.from('[]'));
server.emit('request', mkReq('/v1/asset'), blocked);
- proxy(mkReq('/v1/statistic'), blocked);
+ rejectUnserved(blocked);
const raceRes = fakeRes();
- const piped = new Readable({
+ rejectUnserved(raceRes);
+ proxy(
+ new Readable({
+ read() {
+ this.push(null);
+ },
+ }),
+ raceRes,
+ );
+ const livePipe = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ livePipe.method = 'GET';
+ livePipe.url = '/v1/user';
+ livePipe.headers = { host: '127.0.0.1' };
+ const liveRes = fakeRes();
+ proxy(livePipe, liveRes);
+ const noUrlProxy = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ noUrlProxy.method = 'GET';
+ noUrlProxy.url = undefined;
+ noUrlProxy.headers = { host: '127.0.0.1' };
+ const noUrlProxyRes = fakeRes();
+ proxy(noUrlProxy, noUrlProxyRes);
+ const raced = fakeRes();
+ const racedReq = new Readable({
read() {
this.push(null);
},
});
- piped.method = 'GET';
- piped.url = '/v1/asset';
- piped.headers = { host: '127.0.0.1' };
- piped.destroy = () => {};
- proxy(piped, raceRes);
- raceRes.headersSent = true;
+ racedReq.method = 'GET';
+ racedReq.url = '/v1/user';
+ racedReq.headers = { host: '127.0.0.1' };
+ proxy(racedReq, raced);
+ raced.headersSent = true;
+ raced.writableEnded = true;
+ const closeRes = new EventEmitter();
+ closeRes.headersSent = false;
+ closeRes.writableEnded = false;
+ closeRes.destroyed = false;
+ closeRes.writeHead = function writeHead() {
+ this.headersSent = true;
+ };
+ closeRes.end = function end() {
+ this.writableEnded = true;
+ };
+ const closeReq = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ closeReq.method = 'GET';
+ closeReq.url = '/v1/user';
+ closeReq.headers = { host: '127.0.0.1' };
+ proxy(closeReq, closeRes);
+ closeRes.emit('finish');
+ await sleep(20);
+ closeRes.emit('close');
+ closeReq.emit('aborted');
await sleep(50);
const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
- let got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy buy body');
- got = await request(port, 'PUT', '/v1/sell/quote', buyBody);
- if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy sell body');
+ let got = await request(port, 'PUT', '/v1/buy/quote', buyBody, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy buy body');
+ got = await request(port, 'PUT', '/v1/sell/quote', buyBody, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy sell body');
const swapBody = { sourceAsset: { id: 1 }, targetAsset: { id: 2 }, amount: 0.01 };
- got = await request(port, 'PUT', '/v1/swap/quote', swapBody);
- if (got.status !== 200 || got.body.indexOf('"rate":2') < 0) fail('quote_proxy swap body');
- got = await request(port, 'GET', '/v1/realunit/quote/price');
- if (got.status !== 200 || got.body.indexOf('"price":1') < 0) fail('quote_proxy realunit');
+ got = await request(port, 'PUT', '/v1/swap/quote', swapBody, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy swap body');
+ got = await request(port, 'GET', '/v1/realunit/quote/price', undefined, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('price') < 0) fail('quote_proxy realunit');
+ got = await request(port, 'GET', '/v1/user', undefined, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('user') < 0) fail('unknown GET must be forwarded');
+ got = await request(port, 'GET', '/v1/asset/1', undefined, undefined, 0);
+ if (got.body.indexOf('not served') >= 0) fail('parameterized GET must be forwarded');
const forwarded = seen.filter((row) =>
(row.method === 'PUT' &&
(row.path === '/v1/buy/quote' || row.path === '/v1/sell/quote' || row.path === '/v1/swap/quote')) ||
- (row.method === 'GET' && row.path === '/v1/realunit/quote/price'),
+ (row.method === 'GET' && (row.path === '/v1/realunit/quote/price' || row.path === '/v1/user')),
);
- if (forwarded.length !== 4) fail('quote_forward: expected exactly 4 recorded requests');
- const buyFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/buy/quote');
- const sellFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/sell/quote');
- const swapFwd = forwarded.find((row) => row.method === 'PUT' && row.path === '/v1/swap/quote');
- const ruFwd = forwarded.find((row) => row.method === 'GET' && row.path === '/v1/realunit/quote/price');
- if (!buyFwd || !sellFwd || !swapFwd || !ruFwd) fail('quote_forward: method/path');
- if (JSON.stringify(JSON.parse(buyFwd.body)) !== JSON.stringify(buyBody)) fail('quote_forward: buy body');
- if (JSON.stringify(JSON.parse(sellFwd.body)) !== JSON.stringify(buyBody)) fail('quote_forward: sell body');
- if (JSON.stringify(JSON.parse(swapFwd.body)) !== JSON.stringify(swapBody)) fail('quote_forward: swap body');
- if (buyFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: buy content-type');
- if (sellFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: sell content-type');
- if (swapFwd.contentType.indexOf('application/json') < 0) fail('quote_forward: swap content-type');
+ if (forwarded.length < 4) fail('quote_forward: unknown routes must reach the backend');
setSwaggerSpec(null);
got = await request(port, 'GET', '/swagger-json');
@@ -443,9 +523,10 @@ async function main() {
cache.clear();
got = await request(port, 'GET', '/v1/statistic');
- if (got.status !== 200 || got.headers['x-front-api'] !== 'miss') fail('proxy miss');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('cache miss must not proxy');
+ putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"ok":1}'));
got = await request(port, 'GET', '/v1/statistic');
- if (got.headers['x-front-api'] !== 'hit') fail('proxy hit');
+ if (got.headers['x-front-api'] !== 'hit') fail('cache hit');
setPool({
query: async () => ({
@@ -471,7 +552,7 @@ async function main() {
if (got.status !== 200 || got.headers['x-front-api'] !== 'db') fail('db country');
setPool({ query: async () => null });
got = await request(port, 'GET', '/v1/language');
- if (!got.status) fail('db null');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('db null');
putCache('GET /v1/language', 200, { 'content-type': 'application/json' }, Buffer.from('{"s":1}'));
cache.get('GET /v1/language').exp = Date.now() - 1;
setPool({
@@ -485,10 +566,10 @@ async function main() {
cache.delete('GET /v1/language');
cache.clear();
got = await request(port, 'GET', '/v1/language');
- if (!got.status) fail('db catch proxy');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('db catch must not proxy');
setPool(null);
- got = await request(port, 'GET', '/v1/country', undefined, { authorization: 'Bearer x' });
- if (!got.status) fail('db skip auth');
+ got = await request(port, 'GET', '/v1/country', undefined, { authorization: 'Bearer x' }, 0);
+ if (got.body.indexOf('not served') >= 0) fail('auth GET must be forwarded');
await new Promise((resolve, reject) => {
const held = [];
@@ -510,74 +591,39 @@ async function main() {
hanging.on('error', reject);
});
- await new Promise((resolve, reject) => {
- const t0 = Date.now();
- let settled = false;
- const sock = net.connect(port, '127.0.0.1', () => {
- sock.write(
- 'GET /socket HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-A: 1\r\nX-A: 2\r\n\r\n',
- );
- });
- const done = (err) => {
- if (settled) return;
- settled = true;
- const ms = Date.now() - t0;
- sock.destroy();
- if (err) {
- reject(err);
- return;
- }
- if (ms > 100) {
- reject(new Error('slow upgrade ' + ms + 'ms'));
- return;
- }
- resolve();
- };
- sock.on('error', () => done());
- sock.on('data', () => done());
- setTimeout(() => done(new Error('upgrade hang')), 100);
- });
-
- const liveUp = net.connect({ port: bPort, host: '127.0.0.1' });
- liveUp.on('error', () => {});
- server.emit(
- 'upgrade',
- { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x', skip: undefined, arr: ['a', 'b'] } },
- liveUp,
- Buffer.from('hi'),
- );
- await new Promise((r) => setTimeout(r, 50));
- liveUp.destroy();
-
- const deadClient = new net.Socket();
- deadClient.destroy();
+ const upClient = new net.Socket();
server.emit(
'upgrade',
- { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x' } },
- deadClient,
- null,
- );
- const raceClient = new EventEmitter();
- raceClient.destroyed = false;
- raceClient.destroy = function destroy() {
- this.destroyed = true;
- };
- server.emit(
- 'upgrade',
- { method: 'GET', url: '/', httpVersion: '1.1', headers: { host: 'x' } },
- raceClient,
- Buffer.alloc(0),
+ {
+ method: 'GET',
+ url: '/socket',
+ httpVersion: '1.1',
+ headers: { host: '127.0.0.1', 'x-empty': undefined, 'x-list': ['a', 'b'] },
+ },
+ upClient,
+ Buffer.from('extra'),
);
- raceClient.destroyed = true;
- await sleep(30);
+ await sleep(50);
+ upClient.emit('error', new Error('upgrade client'));
+ upClient.emit('close');
+ upClient.destroy();
+ const deadUp = new net.Socket();
+ deadUp.destroy();
+ server.emit('upgrade', { method: 'GET', url: '/socket', httpVersion: '1.1', headers: {} }, deadUp, Buffer.alloc(0));
+ const alreadyDead = new net.Socket();
+ alreadyDead.destroyed = true;
+ server.emit('upgrade', { method: 'GET', url: '/socket', httpVersion: '1.1', headers: {} }, alreadyDead, Buffer.alloc(0));
+ const upFirst = new net.Socket();
+ server.emit('upgrade', { method: 'GET', url: '/socket', httpVersion: '1.1', headers: {} }, upFirst, Buffer.alloc(0));
+ await sleep(40);
+ const raceSock = new net.Socket();
+ server.emit('upgrade', { method: 'GET', url: '/socket', httpVersion: '1.1', headers: {} }, raceSock, Buffer.alloc(0));
+ raceSock.destroy();
+ await sleep(80);
const sent = fakeRes();
sent.headersSent = true;
- const fakeReq = new http.IncomingMessage(new net.Socket());
- fakeReq.method = 'GET';
- fakeReq.url = '/nope';
- fakeReq.headers = {};
- proxy(fakeReq, sent);
+ rejectUnserved(sent);
const noUrl = fakeRes();
const noUrlReq = new http.IncomingMessage(new net.Socket());
noUrlReq.method = 'GET';
@@ -585,41 +631,63 @@ async function main() {
noUrlReq.headers = {};
server.emit('request', noUrlReq, noUrl);
+ await refreshSwagger();
+ await refreshCache();
+ if (getCached('GET /v1/app')) fail('refreshCache must skip non-200');
+ if (!getCached('GET /')) fail('refreshCache must fill GET /');
+ if (getCached('GET /v1/setting/infoBanner')) fail('refreshCache must not fill nested swagger GET');
+ got = await request(port, 'GET', '/');
+ if (got.status !== 200 || got.body.indexOf('root') < 0) fail('GET / from background cache');
+ got = await request(port, 'GET', '/v1/setting/infoBanner', undefined, undefined, 0);
+ if (got.body.indexOf('not served') >= 0) fail('nested swagger GET must be forwarded');
+ if (got.status !== 200 || got.body.indexOf('banner') < 0) fail('nested swagger GET forwarded body');
+ got = await request(port, 'GET', '/v1/asset?x=1');
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'hit') fail('query must hit path cache');
+ got = await request(port, 'HEAD', '/v1/asset', undefined, undefined, 0);
+ if (got.headers['x-front-api'] === 'hit') fail('HEAD must not be a GET cache hit');
+ if (got.body.indexOf('not served') >= 0) fail('HEAD must be forwarded');
got = await request(port, 'GET', '/v1/asset');
if (got.status !== 200 || got.body.indexOf('BTC') < 0) fail('ttl_expire: prime');
got = await request(port, 'GET', '/v1/asset');
if (got.headers['x-front-api'] !== 'hit') fail('ttl_expire: cache hit before expiry');
await new Promise((r) => setTimeout(r, 2200));
await close(backend);
+ await refreshCache();
got = await request(port, 'GET', '/v1/asset');
if (got.status !== 503) fail('ttl_expire: expected 503');
+ if (got.body.indexOf('not served') < 0) fail('ttl_expire: expected not served');
if (got.body.indexOf('BTC') >= 0) fail('ttl_expire: must not replay expired cache body');
- const deadProxy = fakeRes();
- const deadPipe = new Readable({
+ rejectUnserved(fakeRes());
+ const blockedProxy = fakeRes();
+ blockedProxy.headersSent = true;
+ proxy({ method: 'GET', url: '/v1/user', headers: {}, pipe() {} }, blockedProxy);
+ got = await request(port, 'PUT', '/v1/buy/quote', buyBody, undefined, 0);
+ if (got.status !== 503) fail('quote_proxy dead backend');
+ if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
+ if (got.body.includes('quote unavailable')) fail('quote_proxy must not say quote unavailable');
+ if (got.body.includes('not served')) fail('unknown dead backend must still be forwarded');
+ const late = fakeRes();
+ const lateReq = new Readable({
read() {
this.push(null);
},
});
- deadPipe.method = 'GET';
- deadPipe.url = '/v1/statistic';
- deadPipe.headers = { host: '127.0.0.1' };
- deadPipe.destroy = () => {};
- proxy(deadPipe, deadProxy);
- deadProxy.headersSent = true;
+ lateReq.method = 'GET';
+ lateReq.url = '/v1/user';
+ lateReq.headers = {};
+ proxy(lateReq, late);
+ late.headersSent = true;
+ late.writableEnded = true;
await sleep(50);
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody);
- if (got.status !== 503) fail('quote_proxy dead backend');
- if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
- if (got.body.includes('quote unavailable')) fail('quote_proxy must not say quote unavailable');
cache.clear();
putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"stale":true}'));
cache.get('GET /v1/statistic').exp = Date.now() - 1;
got = await request(port, 'GET', '/v1/statistic');
- if (got.status !== 503) fail('proxy expired cache after backend down');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('expired cache after backend down must be not served');
if (got.body.includes('{"stale":true}')) fail('must not replay expired cache body');
cache.clear();
got = await request(port, 'GET', '/v1/statistic');
- if (got.status !== 503) fail('proxy 503 after backend down');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('miss after backend down must be not served');
const heldHang = [];
const hang = net.createServer((c) => heldHang.push(c));
@@ -628,7 +696,8 @@ async function main() {
hang.on('error', reject);
});
got = await request(port, 'GET', '/v1/coin');
- if (got.status !== 503) fail('proxy hang timeout');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('hanging backend must not be contacted');
+ if (heldHang.length !== 0) fail('hanging backend must receive no client request');
await refreshSwagger();
for (const c of heldHang) c.destroy();
await close(hang);
@@ -784,80 +853,6 @@ process.exit(0);`,
await sleep(40);
if (zReq.destroyed) fail('deadline must not destroy already-destroyed response');
- function mockSock() {
- const sock = new EventEmitter();
- sock.destroyed = false;
- sock.destroy = function destroy() {
- this.destroyed = true;
- this.emit('close');
- };
- return sock;
- }
- const hangClient = mockSock();
- const hangUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, hangClient, hangUp, 15);
- await sleep(40);
- if (!hangClient.destroyed || !hangUp.destroyed) fail('upgrade deadline');
-
- function mockSockQuiet() {
- const sock = new EventEmitter();
- sock.destroyed = false;
- sock.destroy = function destroy() {
- this.destroyed = true;
- };
- return sock;
- }
- const quietClient = mockSockQuiet();
- const quietUp = mockSockQuiet();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, quietClient, quietUp, 15);
- await sleep(40);
- if (!quietClient.destroyed || !quietUp.destroyed) fail('upgrade timer destroy pair');
-
- const okClient = mockSock();
- const okUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, okClient, okUp, 15);
- okUp.emit('data', Buffer.from('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n'));
- okUp.emit('data', Buffer.from('more'));
- await sleep(40);
- if (okClient.destroyed || okUp.destroyed) fail('upgrade handshake ok');
- if (okUp.listenerCount('data') !== 0) fail('upgrade data listener after handshake');
-
- const splitClient = mockSock();
- const splitUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, splitClient, splitUp, 15);
- splitUp.emit('data', Buffer.from('HTTP/1.1 101\r\n'));
- splitUp.emit('data', Buffer.from('\r\n'));
- await sleep(40);
- if (splitClient.destroyed || splitUp.destroyed) fail('upgrade handshake split headers');
-
- const partClient = mockSock();
- const partUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, partClient, partUp, 15);
- partUp.emit('data', Buffer.from('HTTP/1.1 101\r\n'));
- await sleep(40);
- if (!partClient.destroyed || !partUp.destroyed) fail('upgrade incomplete handshake');
-
- const badClient = mockSock();
- const badUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, badClient, badUp, 15);
- badUp.emit('data', Buffer.from('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'));
- await sleep(40);
- if (!badClient.destroyed || !badUp.destroyed) fail('upgrade non-101 must not lift deadline');
-
- const closeClient = mockSock();
- const closeUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, closeClient, closeUp, 15);
- closeUp.emit('data', Buffer.from('HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n'));
- closeUp.emit('close');
- if (!closeClient.destroyed) fail('non-101 up close must cut client');
-
- const closedClient = mockSock();
- const closedUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, closedClient, closedUp, 15);
- closedClient.emit('close');
- await sleep(40);
- if (!closedUp.destroyed) fail('upgrade close must drop backend');
-
const loggedPg = [];
const origPgErr = console.error;
console.error = function error(...args) {
@@ -885,32 +880,6 @@ process.exit(0);`,
if (!loggedPg.some((line) => line.indexOf('pg statement_timeout') >= 0)) fail('pool statement_timeout error');
if (!dropped) fail('pool SET fail must drop client');
- const deadClientSock = mockSock();
- const deadUpSock = mockSock();
- deadClientSock.destroyed = true;
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, deadClientSock, deadUpSock, 15);
- if (!deadUpSock.destroyed) fail('upgrade must drop backend if client already dead');
-
- const deadBothClient = mockSock();
- const deadBothUp = mockSock();
- deadBothClient.destroyed = true;
- deadBothUp.destroyed = true;
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, deadBothClient, deadBothUp, 15);
-
- const skipClient = mockSock();
- const skipUp = mockSock();
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, skipClient, skipUp, 15);
- skipClient.destroyed = true;
- skipUp.destroyed = true;
- await sleep(40);
-
- const closedDeadClient = mockSock();
- const closedDeadUp = mockSock();
- closedDeadUp.destroyed = true;
- attachUpgradeBudget({ method: 'GET', url: '/socket' }, closedDeadClient, closedDeadUp, 15);
- closedDeadClient.emit('close');
- await sleep(40);
-
const { req: fReq, res: fRes } = mockReqRes();
attachResponseBudget(fReq, fRes, 20);
sendJson(fRes, 200, { ok: 1 }, 'local');
diff --git a/test/test-server.sh b/test/test-server.sh
index e5dce51..d8b7500 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -5,13 +5,15 @@
# Arms:
# local one-command start (stub + process) local_start
# swagger snapshot empty → 503 local body swagger_empty
-# PUT /v1/buy/quote → 503 backend unavailable quote_proxy
-# mock records forwarded method/path/body quote_forward
+# PUT /v1/buy/quote is forwarded quote_proxy
+# quotes must reach the backend quote_forward
# expired GET /v1/asset after TTL → 503 ttl_expire
# default CACHE_TTL_MS is 5 minutes cache_ttl_default
-# attachRequestTimeout → callback + destroy proxy_timeout
+# attachRequestTimeout on background refresh only refresh_timeout
# no in-memory quotes / stale cache quotes_gone
-# every HTTP response ≤ 100ms max_response_100
+# known GET ≤ 100ms; unknown is forwarded max_response_100
+# known miss is 503 not served known_local
+# quotes/upgrades forwarded unknown_forward
# c8 100% lines/functions/branches/statements coverage_100
# c8 --all includes every new production .js file coverage_all
set -euo pipefail
@@ -43,6 +45,7 @@ if grep -qE "x-front-api': 'stale'|\"x-front-api\": \"stale\"" "$server_js"; the
fail "server.js must not serve stale cache"
fi
grep -q 'quote_forward' "$test_js" || fail "quote_forward: pin missing"
+grep -Fq 'maxMs === undefined ? 100 : maxMs' "$test_js" || fail "known_local: helper 100ms cap is known routes only"
grep -q 'ttl_expire' "$test_js" || fail "ttl_expire: pin missing"
grep -Fq "CACHE_TTL_MS = '2000'" "$test_js" || fail "ttl_expire: CACHE_TTL_MS pin missing"
grep -Fq 'orFallback(process.env.CACHE_TTL_MS, 300000)' "$server_js" || fail "cache_ttl_default: 5 minutes missing"
@@ -72,18 +75,31 @@ grep -Fq 'REQUEST_TIMEOUT_MS = outboundTimeoutMs(' "$server_js" || fail "max_res
grep -Fq 'if (!Number.isFinite(n) || n <= 0)' "$server_js" || fail "max_response_100: outbound timeout 0/NaN must not disable the cap"
grep -q 'connectionTimeoutMillis: 90' "$server_js" || fail "max_response_100: pool acquire must not outlive the deadline"
grep -Fq 'orFallback(process.env.REQUEST_TIMEOUT_MS, MAX_RESPONSE_MS)' "$server_js" || fail "REQUEST_TIMEOUT_MS default cap"
-grep -q 'attachResponseBudget(req, res)' "$server_js" || fail "max_response_100: inbound budget missing"
-grep -q 'attachUpgradeBudget(req, socket, up)' "$server_js" || fail "max_response_100: upgrade handshake budget missing"
+grep -q 'attachResponseBudget(req, res)' "$server_js" || fail "max_response_100: inbound budget missing on known routes"
+grep -q 'function isKnownLocalRequest' "$server_js" || fail "known_local: must distinguish known GET routes from unknown"
+if ! grep -q 'function proxy' "$server_js"; then
+ fail "unknown_forward: unknown requests must be forwarded"
+fi
+grep -q 'req.pipe' "$server_js" || fail "unknown_forward: must pipe unknown requests outbound"
+grep -q 'net.connect' "$server_js" || fail "unknown_forward: upgrades must be tunnelled"
grep -Fq 'ERROR response exceeded' "$server_js" || fail "max_response_100: production must ERROR-log a deadline miss"
-grep -q 'isUpgradeHandshakeComplete' "$server_js" || fail "max_response_100: upgrade must settle only on a completed 101"
-grep -Fq "up.removeListener('data', onData)" "$server_js" || fail "max_response_100: handshake listener must not outlive the HTTP upgrade"
grep -q "SET statement_timeout TO 90" "$server_js" || fail "max_response_100: pool queries must not outlive the deadline"
grep -q 'limit - 10' "$server_js" || fail "max_response_100: fire before 100ms so the 503 still finishes in budget"
grep -Fq 'if (!canWrite(res)) return;' "$server_js" || fail "max_response_100: writers must refuse after the deadline"
grep -Fq 'if (!res.destroyed) req.destroy();' "$server_js" || fail "max_response_100: deadline must cut an unfinished drain"
grep -Fq "connection: 'close'" "$server_js" || fail "max_response_100: deadline 503 must close the connection"
-grep -Fq "res.on('finish', () => p.destroy())" "$server_js" || fail "max_response_100: proxy must drop outbound when the response finishes"
-grep -q 'forbidden' "$repo_root/CONTRIBUTING.md" || fail "max_response_100: CONTRIBUTING must forbid code that cannot meet 100ms"
+grep -q 'function rejectUnserved' "$server_js" || fail "known_local: uncached known GETs must 503 not served"
+grep -q 'refreshCache' "$server_js" || fail "known_local: GET cache must fill off the request path"
+grep -Fq "['/', ...CACHE_PREFIXES]" "$server_js" || fail "known_local: background refresh must include GET /"
+grep -q 'function cacheRefreshPaths' "$server_js" || fail "known_local: GET cache refresh set is list roots only"
+grep -Fq "req.method !== 'GET'" "$server_js" || fail "known_local: GET cache must not treat HEAD as cacheable"
+grep -Fq 'CACHE_PREFIXES.includes(path)' "$server_js" || fail "known_local: list roots are exact; parameterized paths are forwarded"
+grep -Fq "(req.url ?? '/')" "$server_js" || fail "known_local: request path fallback must use ??"
+grep -Fq "if (!isKnownLocalRequest(req))" "$server_js" || fail "known_local: budget must not wrap forwarded requests"
+grep -Fq "forbidden** to" "$repo_root/CONTRIBUTING.md" || fail "known_local: CONTRIBUTING must forbid waiting on the backend for known routes"
+grep -Fq "no** 100ms" "$repo_root/CONTRIBUTING.md" || fail "unknown_forward: CONTRIBUTING must say forwarded requests have no 100ms rule"
+grep -q 'Unknown routes' "$repo_root/REVIEW.md" || fail "unknown_forward: REVIEW must require forwarding unknown routes"
+grep -q 'forbidden' "$repo_root/CONTRIBUTING.md" || fail "max_response_100: CONTRIBUTING must forbid code that cannot meet 100ms on known routes"
grep -q 'ERROR' "$repo_root/CONTRIBUTING.md" || fail "max_response_100: CONTRIBUTING must require an ERROR log on a deadline miss"
grep -q 'FRONT_API_EXIT_AFTER_BOOT=1' "$repo_root/test/run-main-coverage.sh" || fail "coverage_100: require.main collection missing"
grep -q 'coverage:report' "$pkg" || fail "coverage_100: coverage:report script missing"
From f7927be2af6a80ce32afaad3e8bdd8ccf235a912 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:51:17 +0200
Subject: [PATCH 10/15] 01a02b77 - Never name unknown routes (#11)
* Never name unknown routes
Unknown traffic is only the complement of the known allowlist. Docs,
catalog notes, and the local stub no longer list those routes.
* Pin swagger snapshot drop of non-exact list paths
refreshSwagger must keep only exact known GET paths. The fixture now
includes a child path and a template under a list root and asserts both
are filtered out.
* Assert each unknown probe actually hit the backend
The forward pin now checks PUT/GET /v1/other and GET /v1/asset/x in
the backend seen log instead of a count that coverage probes could
satisfy.
---
CONTRIBUTING.md | 12 ++--
README.md | 7 +--
REVIEW.md | 15 ++---
offered-routes.json | 12 ++--
scripts/local-backend.js | 10 ----
test/local-start.test.js | 12 ++--
test/offered-routes.test.js | 4 +-
test/server.test.js | 108 ++++++++++++------------------------
test/test-server.sh | 11 ++--
9 files changed, 73 insertions(+), 118 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2b142a5..b28c0c7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -127,8 +127,8 @@ When applicable, every pull request must include:
**not** a grant to change that path. Private repositories are not named.
5. **A note in the PR body** when the outward behaviour of this layer changes
(cache, 503 bodies, `x-front-api`, which paths are answered here versus
- forwarded, quote source). Do not name private repositories. Public consumer
- paths belong in `offered-routes.json`.
+ forwarded). Do not name private repositories. Do not name unknown routes.
+ Public consumer paths belong in `offered-routes.json`.
Missing any applicable item = changes requested.
@@ -157,15 +157,15 @@ Missing any applicable item = changes requested.
guarantee 100ms. A cache miss on a known GET is `503` `not served`
immediately — never a live backend fetch on that request.
- Every other request (routes this process does **not** know) is forwarded
- to `BACKEND_URL`. Forwarded requests have **no** 100ms rule. Quotes and
- WebSocket upgrades are unknown here and are forwarded.
+ to `BACKEND_URL`. Forwarded requests have **no** 100ms rule. Unknown
+ routes are **never named** in this repository: they are only the
+ complement of the known allowlist.
- The backend is contacted on the request path only for unknown routes.
Swagger snapshot and GET-cache refresh stay **off** the request path and
exist only to serve known GETs from local state.
- The swagger snapshot is an **allowlist** of paths this process serves, not a
denylist.
-- Authenticated requests are never answered from the GET cache; those
- cache-prefix GETs are unknown here and are forwarded. `GET /version`
+- Authenticated requests are never answered from the GET cache. `GET /version`
and swagger remain local even with `Authorization`.
- Never serve an expired cache body.
- Every **known** HTTP response from this process must complete within
diff --git a/README.md b/README.md
index ba8c1fc..cda4116 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `C
- `GET /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`)
- `GET /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json` — filtered swagger snapshot from the backend; empty snapshot returns 503
-- GET cache (default 5 minutes) for `/` and the public list roots `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`). Nested paths (for example `/v1/setting/infoBanner` or `/v1/asset/1`) are unknown here and are forwarded. HEAD is forwarded.
+- GET cache (default 5 minutes) for `/` and the public list roots `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
- Optional Postgres reads for `GET /v1/country` and `GET /v1/language` when `SQL_HOST` is set
Only fresh cache hits are served for known GETs. The cache is filled in the
@@ -46,9 +46,8 @@ background, not during a client request. After the TTL the next known GET
is `503` `not served` until a background refresh succeeds — never an
expired cache body, never a live backend wait on that request.
-Everything this process does not know (quotes, authenticated calls, other
-methods and paths, WebSocket upgrades) is forwarded to `BACKEND_URL` with
-no 100ms rule.
+Everything this process does not know is forwarded to `BACKEND_URL` with
+no 100ms rule. This repository does not name those routes.
Every **known** HTTP response must finish within 100ms. Forwarding a known
route is forbidden because that cannot guarantee 100ms. A slower known
diff --git a/REVIEW.md b/REVIEW.md
index 5f0fee7..490171b 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -78,7 +78,7 @@ CONTRIBUTING.md or lands untested.
## 10. Outward behaviour of this layer
-If cache, 503 body, `x-front-api`, self-answered paths, or quote source change:
+If cache, 503 body, `x-front-api`, or self-answered paths change:
it is said in the PR body, a pin is present, and the swagger allowlist matches.
## 11. Usage catalog and frontend E2E
@@ -90,7 +90,7 @@ when a served path has no row or a row has empty fields. That is not
enough to merge.
- Fail if the pull request adds, removes, or changes how a self-answered
- path answers (status, body, cache, quote source, allowlist) and that
+ path answers (status, body, cache, allowlist) and that
row's `e2e` is `unidentified` **or** the named E2E does not actually
cover that function **including the frontend**.
- The E2E may live in another public repository. It need not be on that
@@ -129,8 +129,9 @@ test round-trip is allowed to take longer. Forwarding a known route is a
hard fail: it cannot guarantee 100ms. A slower ping of a known route is a
hard bug, not a performance note.
-Unknown routes (everything this process does not answer itself, including
-quotes and WebSocket upgrades) **must** be forwarded to `BACKEND_URL`.
-They have no 100ms rule. Fail if an unknown request is answered with
-`503` `not served` instead of being forwarded, or if the 100ms budget is
-attached to the forward path.
+Unknown routes (everything this process does not answer itself) **must**
+be forwarded to `BACKEND_URL`. They have no 100ms rule. This repository
+never names them. Fail if an unknown request is answered with `503`
+`not served` instead of being forwarded, if the 100ms budget is attached
+to the forward path, or if the diff names a route outside the known
+allowlist in docs, comments, catalog notes, or PR text.
diff --git a/offered-routes.json b/offered-routes.json
index 11ed2e2..93d8a02 100644
--- a/offered-routes.json
+++ b/offered-routes.json
@@ -87,7 +87,7 @@
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/asset.hook.ts",
- "note": "GET AssetUrl.get (asset). The list root is served from the background GET cache; a miss is 503 not served. Parameterized subpaths such as /v1/asset/1 are forwarded."
+ "note": "GET AssetUrl.get (asset). The exact list root is served from the background GET cache; a miss is 503 not served."
},
{
"repo": "DFXswiss/services",
@@ -182,7 +182,7 @@
"usedIn": [
{
"unidentified": true,
- "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
@@ -199,7 +199,7 @@
"usedIn": [
{
"unidentified": true,
- "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
@@ -216,13 +216,13 @@
"usedIn": [
{
"unidentified": true,
- "note": "List root GET /v1/setting is served from the background cache. Nested GET /v1/setting/infoBanner is forwarded, not answered here."
+ "note": "Exact list root GET /v1/setting is served from the background cache."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that GETs the list root /v1/setting live. Nested infoBanner is forwarded."
+ "note": "No frontend E2E named that GETs the list root /v1/setting live."
}
]
},
@@ -251,7 +251,7 @@
"usedIn": [
{
"unidentified": true,
- "note": "List root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. Nested paths are forwarded. No named call site in DFXswiss/packages."
+ "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages."
}
],
"e2e": [
diff --git a/scripts/local-backend.js b/scripts/local-backend.js
index 8a1822d..b5fe9fd 100644
--- a/scripts/local-backend.js
+++ b/scripts/local-backend.js
@@ -88,7 +88,6 @@ function swaggerDocument() {
};
for (const prefix of PREFIXES) paths[prefix] = { get: {} };
- paths['/v1/buy/quote'] = { put: {} };
return {
openapi: '3.0.0',
@@ -137,15 +136,6 @@ function createLocalBackend() {
}
}
- if (method === 'PUT' && path === '/v1/buy/quote') {
- respondAfterDrain(req, res, {
- price: 1,
- from: { amount: 1 },
- to: { amount: 1 },
- });
- return;
- }
-
const body = {
proxied: true,
method,
diff --git a/test/local-start.test.js b/test/local-start.test.js
index e69154d..86e9517 100644
--- a/test/local-start.test.js
+++ b/test/local-start.test.js
@@ -118,11 +118,11 @@ async function main() {
const asset = await request(port, 'GET', '/v1/asset');
const assetExtra = await request(port, 'GET', '/v1/asset/extra');
const swagger = await request(port, 'GET', '/swagger-json');
- const quote = await request(port, 'PUT', '/v1/buy/quote', '{"amount":1}');
+ const other = await request(port, 'PUT', '/v1/other', '{"amount":1}');
const country = await request(port, 'GET', '/v1/country');
const language = await request(port, 'GET', '/v1/language');
- for (const response of [asset, assetExtra, swagger, quote, country, language]) {
+ for (const response of [asset, assetExtra, swagger, other, country, language]) {
assert(response.statusCode === 200, 'stub response status must be 200');
assert(response.duration <= 100, `stub response exceeded 100ms: ${response.duration}ms`);
try {
@@ -157,13 +157,13 @@ async function main() {
assert(Array.isArray(language.json), 'language fixture must be an array');
assert(language.json[0].symbol === 'EN', 'language fixture symbol must be EN');
- assert(quote.json.price === 1, 'quote price must be 1');
- assert(quote.json.from.amount === 1, 'quote from amount must be 1');
- assert(quote.json.to.amount === 1, 'quote to amount must be 1');
+ assert(other.json.proxied === true, 'unknown path must be answered by the stub as forwarded');
+ assert(other.json.method === 'PUT', 'unknown stub method must echo PUT');
+ assert(other.json.path === '/v1/other', 'unknown stub path must echo the request path');
assert(swagger.json.info.title, 'swagger info.title must be present');
assert(swagger.json.paths['/v1/asset'] !== undefined, 'swagger must contain /v1/asset');
- assert(swagger.json.paths['/v1/user'] === undefined, 'swagger must not contain /v1/user');
+ assert(Object.keys(swagger.json.paths).every((p) => p === '/version' || p === '/' || PREFIXES.includes(p)), 'swagger must only list known paths');
assert(swagger.json.paths['/version'] !== undefined, 'swagger must contain /version');
for (const prefix of PREFIXES) {
assert(swagger.json.paths[prefix] !== undefined, `swagger must contain ${prefix}`);
diff --git a/test/offered-routes.test.js b/test/offered-routes.test.js
index 4647cf2..769e854 100644
--- a/test/offered-routes.test.js
+++ b/test/offered-routes.test.js
@@ -98,7 +98,7 @@ for (const key of seen) {
if (!expectedKeys.has(key)) fail('unexpected catalog row: ' + key);
}
-if (isServedPath('/v1/user')) fail('isServedPath unexpectedly true for /v1/user');
-if (catalogCovers('GET', '/v1/user')) fail('unserved /v1/user must not be in the catalog');
+if (isServedPath('/v1/other')) fail('isServedPath unexpectedly true outside the allowlist');
+if (catalogCovers('GET', '/v1/other')) fail('a path outside the allowlist must not be in the catalog');
console.log('ok offered-routes.json', catalog.routes.length, 'rows');
diff --git a/test/server.test.js b/test/server.test.js
index ae42b0a..f92b4ba 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -162,15 +162,13 @@ async function main() {
{ id: 11, name: 'EUR' },
{ id: 12, name: 'USD' },
];
- const quote = { rate: 2, fees: { rate: 0.01, fixed: 0 } };
- const ram = { price: 1 };
const swagger = {
paths: {
'/v1/asset': { get: {} },
- '/v1/user': { get: {} },
- '/version': { get: {} },
- '/v1/setting/infoBanner': { get: {} },
+ '/v1/asset/x': { get: {} },
'/v1/asset/{id}': { get: {} },
+ '/v1/other': { get: {} },
+ '/version': { get: {} },
'/v1/bank': { post: {} },
},
};
@@ -180,29 +178,17 @@ async function main() {
jsonHandler({
'/v1/asset': assets,
'/v1/fiat': fiats,
- '/v1/buy/quote': quote,
- '/v1/sell/quote': quote,
- '/v1/swap/quote': quote,
- '/v1/realunit/quote/buyPrice': ram,
- '/v1/realunit/quote/buyShares': ram,
- '/v1/realunit/quote/info': ram,
- '/v1/realunit/quote/price': ram,
- '/v1/realunit/brokerbot/buyPrice': ram,
- '/v1/realunit/brokerbot/buyShares': ram,
- '/v1/realunit/brokerbot/info': ram,
- '/v1/realunit/brokerbot/price': ram,
'/': { root: 1 },
'/swagger-json': swagger,
'/v1/statistic': { ok: 1 },
'/v1/setting': { ok: 1 },
- '/v1/setting/infoBanner': { banner: 1 },
'/v1/bank': { ok: 1 },
'/v1/app': (req, res) => {
res.writeHead(500, { 'content-type': 'application/json' });
res.end('{"ok":false}');
},
'/v1/coin': { ok: 1 },
- '/v1/user': { user: 1 },
+ '/v1/other': { other: 1 },
}, seen),
);
const bPort = await listen(backend);
@@ -285,31 +271,26 @@ async function main() {
if (!isServedPath('/version') || !isServedPath('/swagger/') || !isServedPath('/swagger-json/')) fail('isServedPath meta');
if (!isServedPath('/swagger-ui') || !isServedPath('/swagger-ui/')) fail('isServedPath ui');
- if (isServedPath('/v1/buy/quote') || isServedPath('/v1/sell/quote') || isServedPath('/v1/swap/quote')) {
- fail('isServedPath quotes');
- }
+ if (isServedPath('/v1/other')) fail('isServedPath outside allowlist');
if (!isServedPath('/v1/asset') || !isServedPath(undefined)) fail('isServedPath');
- if (isServedPath('/v1/asset/{id}')) fail('isServedPath template');
- if (isServedPath('/v1/realunit/quote/price')) fail('isServedPath ram');
- if (isServedPath('/v1/user')) fail('isServedPath user');
if (!isKnownLocalRequest({ method: 'GET', url: '/v1/asset', headers: {} })) fail('known GET asset');
if (!isKnownLocalRequest({ method: 'GET', url: '/version', headers: {} })) fail('known version');
- if (isKnownLocalRequest({ method: 'PUT', url: '/v1/buy/quote', headers: {} })) fail('unknown quote');
- if (isKnownLocalRequest({ method: 'GET', url: '/v1/user', headers: {} })) fail('unknown user');
- if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('unknown auth GET');
- if (isKnownLocalRequest({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('unknown HEAD');
- if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('unknown asset id');
+ if (isKnownLocalRequest({ method: 'PUT', url: '/v1/other', headers: {} })) fail('unknown method');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/other', headers: {} })) fail('unknown path');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('auth GET is not a cache hit path');
+ if (isKnownLocalRequest({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('non-GET is not known local');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/asset/x', headers: {} })) fail('non-exact list path is not known local');
if (!isKnownLocalRequest({ method: 'GET', url: '/swagger-json', headers: { authorization: 'x' } })) fail('known swagger ignores auth');
if (!isCacheable({ method: 'GET', url: '/v1/asset', headers: {} })) fail('cache GET');
- if (isCacheable({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('cache nested id');
+ if (isCacheable({ method: 'GET', url: '/v1/asset/x', headers: {} })) fail('cache non-exact list path');
if (isCacheable({ method: 'HEAD', url: '/', headers: {} })) fail('cache HEAD');
if (!isCacheable({ method: 'GET', url: '/version', headers: {} })) fail('cache version');
if (!isCacheable({ method: 'GET', url: '/swagger', headers: {} })) fail('cache swagger');
if (!isCacheable({ method: 'GET', url: '/swagger-json', headers: {} })) fail('cache swagger-json');
if (isCacheable({ method: 'PUT', url: '/v1/asset', headers: {} })) fail('cache PUT');
if (isCacheable({ method: 'GET', url: '/v1/asset', headers: { authorization: 'x' } })) fail('cache auth');
- if (isCacheable({ method: 'GET', url: '/v1/user', headers: {} })) fail('cache user');
+ if (isCacheable({ method: 'GET', url: '/v1/other', headers: {} })) fail('cache outside allowlist');
if (cacheKey({ method: 'GET', url: '/a' }) !== 'GET /a') fail('cacheKey');
if (cacheKey({ method: 'GET', url: '/v1/asset?x=1' }) !== 'GET /v1/asset') fail('cacheKey query');
if (cacheKey({ method: 'GET', url: undefined }) !== 'GET /') fail('cacheKey empty');
@@ -383,17 +364,17 @@ async function main() {
setPool(null);
await refreshSwagger();
- if (isCacheable({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('nested swagger GET is unknown');
- if (isKnownLocalRequest({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('infoBanner is forwarded');
- if (!getSwaggerSpec() || !getSwaggerSpec().paths['/v1/asset'] || getSwaggerSpec().paths['/v1/user']) {
+ if (isCacheable({ method: 'GET', url: '/v1/other', headers: {} })) fail('outside allowlist is unknown');
+ if (isKnownLocalRequest({ method: 'GET', url: '/v1/other', headers: {} })) fail('outside allowlist is forwarded');
+ if (!getSwaggerSpec() || !getSwaggerSpec().paths['/v1/asset'] || getSwaggerSpec().paths['/v1/other']) {
fail('refreshSwagger allowlist');
}
- if (getSwaggerSpec().paths['/v1/setting/infoBanner'] || getSwaggerSpec().paths['/v1/asset/{id}']) {
- fail('refreshSwagger must drop nested and templates');
+ if (getSwaggerSpec().paths['/v1/asset/x'] || getSwaggerSpec().paths['/v1/asset/{id}']) {
+ fail('refreshSwagger must drop non-exact list paths');
}
const refreshPaths = cacheRefreshPaths();
if (!refreshPaths.includes('/') || !refreshPaths.includes('/v1/asset')) fail('cacheRefreshPaths roots');
- if (refreshPaths.includes('/v1/setting/infoBanner')) fail('cacheRefreshPaths nested');
+ if (refreshPaths.includes('/v1/other')) fail('cacheRefreshPaths outside allowlist');
if (refreshPaths.includes('/version')) fail('cacheRefreshPaths local version');
const port = await listen(server);
@@ -428,7 +409,7 @@ async function main() {
},
});
livePipe.method = 'GET';
- livePipe.url = '/v1/user';
+ livePipe.url = '/v1/other';
livePipe.headers = { host: '127.0.0.1' };
const liveRes = fakeRes();
proxy(livePipe, liveRes);
@@ -449,7 +430,7 @@ async function main() {
},
});
racedReq.method = 'GET';
- racedReq.url = '/v1/user';
+ racedReq.url = '/v1/other';
racedReq.headers = { host: '127.0.0.1' };
proxy(racedReq, raced);
raced.headersSent = true;
@@ -470,7 +451,7 @@ async function main() {
},
});
closeReq.method = 'GET';
- closeReq.url = '/v1/user';
+ closeReq.url = '/v1/other';
closeReq.headers = { host: '127.0.0.1' };
proxy(closeReq, closeRes);
closeRes.emit('finish');
@@ -479,27 +460,16 @@ async function main() {
closeReq.emit('aborted');
await sleep(50);
- const buyBody = { currency: { id: 1 }, asset: { id: 2 }, amount: 100, paymentMethod: 'Bank' };
- let got = await request(port, 'PUT', '/v1/buy/quote', buyBody, undefined, 0);
- if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy buy body');
- got = await request(port, 'PUT', '/v1/sell/quote', buyBody, undefined, 0);
- if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy sell body');
- const swapBody = { sourceAsset: { id: 1 }, targetAsset: { id: 2 }, amount: 0.01 };
- got = await request(port, 'PUT', '/v1/swap/quote', swapBody, undefined, 0);
- if (got.status !== 200 || got.body.indexOf('rate') < 0) fail('quote_proxy swap body');
- got = await request(port, 'GET', '/v1/realunit/quote/price', undefined, undefined, 0);
- if (got.status !== 200 || got.body.indexOf('price') < 0) fail('quote_proxy realunit');
- got = await request(port, 'GET', '/v1/user', undefined, undefined, 0);
- if (got.status !== 200 || got.body.indexOf('user') < 0) fail('unknown GET must be forwarded');
- got = await request(port, 'GET', '/v1/asset/1', undefined, undefined, 0);
- if (got.body.indexOf('not served') >= 0) fail('parameterized GET must be forwarded');
-
- const forwarded = seen.filter((row) =>
- (row.method === 'PUT' &&
- (row.path === '/v1/buy/quote' || row.path === '/v1/sell/quote' || row.path === '/v1/swap/quote')) ||
- (row.method === 'GET' && (row.path === '/v1/realunit/quote/price' || row.path === '/v1/user')),
- );
- if (forwarded.length < 4) fail('quote_forward: unknown routes must reach the backend');
+ let got = await request(port, 'PUT', '/v1/other', { n: 1 }, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('other') < 0) fail('unknown_forward put body');
+ got = await request(port, 'GET', '/v1/other', undefined, undefined, 0);
+ if (got.status !== 200 || got.body.indexOf('other') < 0) fail('unknown_forward get body');
+ got = await request(port, 'GET', '/v1/asset/x', undefined, undefined, 0);
+ if (got.body.indexOf('not served') >= 0) fail('non-exact list path must be forwarded');
+
+ if (!seen.some((row) => row.method === 'PUT' && row.path === '/v1/other')) fail('unknown_forward put');
+ if (!seen.some((row) => row.method === 'GET' && row.path === '/v1/other')) fail('unknown_forward get');
+ if (!seen.some((row) => row.method === 'GET' && row.path === '/v1/asset/x')) fail('unknown_forward non-exact list path');
setSwaggerSpec(null);
got = await request(port, 'GET', '/swagger-json');
@@ -635,12 +605,9 @@ async function main() {
await refreshCache();
if (getCached('GET /v1/app')) fail('refreshCache must skip non-200');
if (!getCached('GET /')) fail('refreshCache must fill GET /');
- if (getCached('GET /v1/setting/infoBanner')) fail('refreshCache must not fill nested swagger GET');
+ if (getCached('GET /v1/other')) fail('refreshCache must not fill a path outside the allowlist');
got = await request(port, 'GET', '/');
if (got.status !== 200 || got.body.indexOf('root') < 0) fail('GET / from background cache');
- got = await request(port, 'GET', '/v1/setting/infoBanner', undefined, undefined, 0);
- if (got.body.indexOf('not served') >= 0) fail('nested swagger GET must be forwarded');
- if (got.status !== 200 || got.body.indexOf('banner') < 0) fail('nested swagger GET forwarded body');
got = await request(port, 'GET', '/v1/asset?x=1');
if (got.status !== 200 || got.headers['x-front-api'] !== 'hit') fail('query must hit path cache');
got = await request(port, 'HEAD', '/v1/asset', undefined, undefined, 0);
@@ -660,11 +627,10 @@ async function main() {
rejectUnserved(fakeRes());
const blockedProxy = fakeRes();
blockedProxy.headersSent = true;
- proxy({ method: 'GET', url: '/v1/user', headers: {}, pipe() {} }, blockedProxy);
- got = await request(port, 'PUT', '/v1/buy/quote', buyBody, undefined, 0);
- if (got.status !== 503) fail('quote_proxy dead backend');
- if (!got.body.includes('backend-api unavailable')) fail('quote_proxy dead body');
- if (got.body.includes('quote unavailable')) fail('quote_proxy must not say quote unavailable');
+ proxy({ method: 'GET', url: '/v1/other', headers: {}, pipe() {} }, blockedProxy);
+ got = await request(port, 'PUT', '/v1/other', { n: 1 }, undefined, 0);
+ if (got.status !== 503) fail('unknown_forward dead backend');
+ if (!got.body.includes('backend-api unavailable')) fail('unknown_forward dead body');
if (got.body.includes('not served')) fail('unknown dead backend must still be forwarded');
const late = fakeRes();
const lateReq = new Readable({
@@ -673,7 +639,7 @@ async function main() {
},
});
lateReq.method = 'GET';
- lateReq.url = '/v1/user';
+ lateReq.url = '/v1/other';
lateReq.headers = {};
proxy(lateReq, late);
late.headersSent = true;
diff --git a/test/test-server.sh b/test/test-server.sh
index d8b7500..e64abc3 100755
--- a/test/test-server.sh
+++ b/test/test-server.sh
@@ -5,15 +5,13 @@
# Arms:
# local one-command start (stub + process) local_start
# swagger snapshot empty → 503 local body swagger_empty
-# PUT /v1/buy/quote is forwarded quote_proxy
-# quotes must reach the backend quote_forward
+# a path outside the allowlist is forwarded unknown_forward
# expired GET /v1/asset after TTL → 503 ttl_expire
# default CACHE_TTL_MS is 5 minutes cache_ttl_default
# attachRequestTimeout on background refresh only refresh_timeout
-# no in-memory quotes / stale cache quotes_gone
+# no in-memory special-case book / stale cache quotes_gone
# known GET ≤ 100ms; unknown is forwarded max_response_100
# known miss is 503 not served known_local
-# quotes/upgrades forwarded unknown_forward
# c8 100% lines/functions/branches/statements coverage_100
# c8 --all includes every new production .js file coverage_all
set -euo pipefail
@@ -44,7 +42,8 @@ done
if grep -qE "x-front-api': 'stale'|\"x-front-api\": \"stale\"" "$server_js"; then
fail "server.js must not serve stale cache"
fi
-grep -q 'quote_forward' "$test_js" || fail "quote_forward: pin missing"
+grep -q 'unknown_forward' "$test_js" || fail "unknown_forward: pin missing"
+grep -Fq 'never named' "$repo_root/CONTRIBUTING.md" || fail "unknown_forward: CONTRIBUTING must forbid naming unknown routes"
grep -Fq 'maxMs === undefined ? 100 : maxMs' "$test_js" || fail "known_local: helper 100ms cap is known routes only"
grep -q 'ttl_expire' "$test_js" || fail "ttl_expire: pin missing"
grep -Fq "CACHE_TTL_MS = '2000'" "$test_js" || fail "ttl_expire: CACHE_TTL_MS pin missing"
@@ -93,7 +92,7 @@ grep -q 'refreshCache' "$server_js" || fail "known_local: GET cache must fill of
grep -Fq "['/', ...CACHE_PREFIXES]" "$server_js" || fail "known_local: background refresh must include GET /"
grep -q 'function cacheRefreshPaths' "$server_js" || fail "known_local: GET cache refresh set is list roots only"
grep -Fq "req.method !== 'GET'" "$server_js" || fail "known_local: GET cache must not treat HEAD as cacheable"
-grep -Fq 'CACHE_PREFIXES.includes(path)' "$server_js" || fail "known_local: list roots are exact; parameterized paths are forwarded"
+grep -Fq 'CACHE_PREFIXES.includes(path)' "$server_js" || fail "known_local: list roots are exact matches"
grep -Fq "(req.url ?? '/')" "$server_js" || fail "known_local: request path fallback must use ??"
grep -Fq "if (!isKnownLocalRequest(req))" "$server_js" || fail "known_local: budget must not wrap forwarded requests"
grep -Fq "forbidden** to" "$repo_root/CONTRIBUTING.md" || fail "known_local: CONTRIBUTING must forbid waiting on the backend for known routes"
From 91f9c9a7d03b1386cce6bf04c9f240673474d93e Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Mon, 24 Aug 2026 10:33:59 +0200
Subject: [PATCH 11/15] 01a02ecf - Forbid forwarding listed routes (#12)
* Forbid forwarding listed routes to the backend
A route this process lists is completed from local state. Nested prefix
paths, HEAD, and authenticated listed GETs are no longer forwarded.
Client requests never wait on BACKEND_URL; background refresh may ping it.
* Align unknown-route probes with the unnamed complement
Keep listed nested swagger paths in the local fixture and stop naming
unlisted product routes in the suite.
* Fail closed listed WebSocket upgrades
A listed GET/HEAD must not be tunnelled to the upstream HTTP backend.
Unknown upgrades stay forwarded.
* Point offered-route E2E at live widget and KYC specs
Asset list coverage includes the smoke fetch. Country coverage uses the
KYC personal-data country dropdown. Setting usedIn names the public hook.
---
CONTRIBUTING.md | 39 ++++++++------
README.md | 13 ++---
REVIEW.md | 24 +++++----
offered-routes.json | 46 +++++++++-------
server.js | 83 +++++++++++++++++------------
test/offered-routes.test.js | 9 ++--
test/server.test.js | 101 +++++++++++++++++++++++++++++-------
test/test-server.sh | 15 ++++--
8 files changed, 220 insertions(+), 110 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b28c0c7..6db1aa2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -150,23 +150,30 @@ Missing any applicable item = changes requested.
## This process
-- This process answers a **fixed** set of routes itself from local state
- (version, swagger snapshot, fresh GET cache, optional Postgres). Those
- **known** routes must finish within **100ms**. It is **forbidden** to
- satisfy them by waiting on `BACKEND_URL` or any other system that cannot
- guarantee 100ms. A cache miss on a known GET is `503` `not served`
- immediately — never a live backend fetch on that request.
-- Every other request (routes this process does **not** know) is forwarded
- to `BACKEND_URL`. Forwarded requests have **no** 100ms rule. Unknown
+- This process answers a **fixed listed set** (`offered-routes.json`,
+ `isServedPath`). A listed route is **finished here** from local state
+ (version, swagger snapshot, fresh GET/HEAD cache, optional Postgres). It is
+ **forbidden** to document a route here and then forward that request to
+ `BACKEND_URL`.
+- A listed client request must **never wait** on `BACKEND_URL`. A cache miss,
+ empty swagger snapshot, or missing database row is `503` `not served`
+ immediately. Background refresh may ping `BACKEND_URL` off the request path
+ and must not delay the response.
+- Listed GET/HEAD paths are exact `/`, `/version`, the swagger aliases, and
+ `CACHE_PREFIXES` as prefixes (`path === p || path.startsWith(p + '/')`).
+ Nested paths under a listed prefix are listed.
+- `Authorization` does not make a listed GET unknown. Do not answer an
+ authenticated GET from the unauthenticated GET cache; when no other local
+ source exists, answer `503` `not served` — never forward.
+- HEAD on a listed path is listed. It follows the same local body rules as GET
+ and sends an empty response body.
+- Unlisted requests (everything for which `isKnownLocalRequest` is false)
+ remain forwarded. Forwarded requests have **no** 100ms rule. Unknown
routes are **never named** in this repository: they are only the
- complement of the known allowlist.
-- The backend is contacted on the request path only for unknown routes.
- Swagger snapshot and GET-cache refresh stay **off** the request path and
- exist only to serve known GETs from local state.
+ complement of the listed allowlist. Do not attach the 100ms budget to the
+ forward path.
- The swagger snapshot is an **allowlist** of paths this process serves, not a
denylist.
-- Authenticated requests are never answered from the GET cache. `GET /version`
- and swagger remain local even with `Authorization`.
- Never serve an expired cache body.
- Every **known** HTTP response from this process must complete within
**100ms**. That bound is technical and always enforced, not a target. The
@@ -209,7 +216,9 @@ There is no production JavaScript in this repository that may ship below 100%
coverage. The coverage gate is the CI job, not a review courtesy.
There is no **known** HTTP response this process may take longer than 100ms
-to finish. Unknown requests are forwarded and are not in that budget.
+to finish. Known/listed routes are never forwarded. Unknown/unlisted requests
+are forwarded and are not in that budget. Nested listed prefixes, HEAD on
+listed paths, and authenticated listed GETs are listed, not unknown.
`test/test-server.sh` pins `MAX_RESPONSE_MS = 100`, the inbound deadline on
known routes, that known routes are not forwarded, that unknown routes are
forwarded, the `ERROR` log, and the background outbound cap. The Node suite
diff --git a/README.md b/README.md
index cda4116..a3d4e9f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# front-api
-Public HTTP layer in front of the DFX backend. Known routes (`/version`, a filtered swagger snapshot, GET cache, optional Postgres reads for country/language) are answered locally within 100ms and never wait on `BACKEND_URL`. Everything else is forwarded.
+Public HTTP layer in front of the DFX backend. Listed routes (`/version`, a filtered swagger snapshot, GET/HEAD cache, optional Postgres reads for country/language) are completed locally within 100ms, never forwarded, and never wait on `BACKEND_URL` for that client request. Background cache and swagger refresh may ping the upstream HTTP backend. Unlisted traffic is forwarded.
## Run
@@ -36,17 +36,18 @@ Optional: `PORT` (3000), `BIND` (`0.0.0.0`), `CACHE_TTL_MS` (default 300000), `C
## Local answers
-- `GET /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`)
-- `GET /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json` — filtered swagger snapshot from the backend; empty snapshot returns 503
-- GET cache (default 5 minutes) for `/` and the public list roots `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`)
-- Optional Postgres reads for `GET /v1/country` and `GET /v1/language` when `SQL_HOST` is set
+- `GET`/`HEAD /version` — answered locally (JSON, or HTML when `Accept` includes `text/html`); HEAD has an empty body
+- `GET`/`HEAD /swagger`, `/swagger/`, `/swagger-ui`, `/swagger-ui/`, `/swagger-json`, `/swagger-json/` — filtered swagger snapshot from the upstream HTTP backend; an empty snapshot returns 503 locally
+- GET/HEAD cache (default 5 minutes) for `/` and the public prefixes `/v1/asset`, `/v1/fiat`, `/v1/country`, `/v1/language`, `/v1/statistic`, `/v1/coin`, `/v1/setting`, `/v1/bank`, `/v1/app` (no `Authorization`). Nested paths under these prefixes are listed. HEAD follows the same local rules as GET and has an empty body.
+- Optional Postgres reads for `GET`/`HEAD /v1/country` and `GET`/`HEAD /v1/language` when `SQL_HOST` is set
+- An authenticated listed GET/HEAD never reads the unauthenticated GET cache. Without another local source it returns `503` `not served`; it is never forwarded.
Only fresh cache hits are served for known GETs. The cache is filled in the
background, not during a client request. After the TTL the next known GET
is `503` `not served` until a background refresh succeeds — never an
expired cache body, never a live backend wait on that request.
-Everything this process does not know is forwarded to `BACKEND_URL` with
+Everything this process does not list is forwarded to `BACKEND_URL` with
no 100ms rule. This repository does not name those routes.
Every **known** HTTP response must finish within 100ms. Forwarding a known
diff --git a/REVIEW.md b/REVIEW.md
index 490171b..2ea284e 100644
--- a/REVIEW.md
+++ b/REVIEW.md
@@ -117,17 +117,19 @@ function unless the reviewer grants that in writing.
## 12. Known routes: 100ms. Unknown routes: forwarded
-This process knows a fixed set of local GET routes (version, swagger
-snapshot, fresh GET cache, optional Postgres). Every **known** HTTP
-response must finish within 100ms. Fail if `MAX_RESPONSE_MS` is not 100,
-if `REQUEST_TIMEOUT_MS` can exceed 100 for background refresh, if the
-inbound budget is missing on a known route, if a known route is forwarded
-to the backend or waits on any system that cannot guarantee 100ms, if a
-deadline miss on a known route does not emit an `ERROR` log, if the change
-adds a known path that cannot finish in 100ms, or if a **known-route**
-test round-trip is allowed to take longer. Forwarding a known route is a
-hard fail: it cannot guarantee 100ms. A slower ping of a known route is a
-hard bug, not a performance note.
+This process knows a fixed listed set of local GET/HEAD routes (version,
+swagger snapshot, fresh GET/HEAD cache, optional Postgres). Every **known**
+HTTP response must finish within 100ms. Fail if `MAX_RESPONSE_MS` is not 100,
+if `REQUEST_TIMEOUT_MS` can exceed 100 for background refresh, if the inbound
+budget is missing on a known route, if a listed route is forwarded or waits on
+`BACKEND_URL` on the request path, if a deadline miss on a known route does not
+emit an `ERROR` log, if the change adds a known path that cannot finish in
+100ms, or if a **known-route** test round-trip is allowed to take longer.
+Forwarding a listed route is a hard fail.
+
+Fail if the catalog says `prefix` or `exact` but the code forwards a matching
+nested GET/HEAD request or an authenticated listed GET. Listed authenticated
+GETs must not read the unauthenticated GET cache.
Unknown routes (everything this process does not answer itself) **must**
be forwarded to `BACKEND_URL`. They have no 100ms rule. This repository
diff --git a/offered-routes.json b/offered-routes.json
index 93d8a02..4116ed8 100644
--- a/offered-routes.json
+++ b/offered-routes.json
@@ -1,6 +1,6 @@
{
"title": "Offered routes: usage and frontend E2E",
- "rules": "Every path this process answers itself has a row. usedIn is a public consumer repo+file, or unidentified:true with a note when no public call site is named. e2e is a frontend-inclusive E2E in a public repo (any branch), or unidentified:true when none is named. This repo's CI checks fields only and does not run foreign suites. Private repositories are not named. Backend routes this process does not serve are not listed.",
+ "rules": "Every path this process answers itself has a row. Exact rows list only their names and aliases; prefix rows also list every nested path. Listed GET/HEAD requests are answered locally; a miss is 503 not served, never forwarded. usedIn is a public consumer repo+file, or unidentified:true with a note when no public call site is named. e2e is a frontend-inclusive E2E in a public repo (any branch), or unidentified:true when none is named. This repo's CI checks fields only and does not run foreign suites. Private repositories are not named. Backend routes this process does not serve are not listed.",
"routes": [
{
"method": "GET",
@@ -82,12 +82,12 @@
{
"method": "GET",
"path": "/v1/asset",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"repo": "DFXswiss/packages",
"path": "packages/react/src/hooks/asset.hook.ts",
- "note": "GET AssetUrl.get (asset). The exact list root is served from the background GET cache; a miss is 503 not served."
+ "note": "GET AssetUrl.get (asset). This prefix and its nested paths are served from the background GET cache; a miss is 503 not served, never forwarded."
},
{
"repo": "DFXswiss/services",
@@ -99,14 +99,19 @@
{
"repo": "DFXswiss/services",
"path": "e2e-stack/specs/buy.spec.ts",
- "note": "Widget buy flow: frontend Playwright asserts against GET /v1/asset."
+ "note": "Widget buy flow: frontend Playwright asserts against live GET /v1/asset."
+ },
+ {
+ "repo": "DFXswiss/services",
+ "path": "e2e-stack/specs/smoke.spec.ts",
+ "note": "Smoke fetch of GET /v1/asset from the running stack."
}
]
},
{
"method": "GET",
"path": "/v1/fiat",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"repo": "DFXswiss/packages",
@@ -130,7 +135,7 @@
{
"method": "GET",
"path": "/v1/country",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"repo": "DFXswiss/packages",
@@ -146,15 +151,15 @@
"e2e": [
{
"repo": "DFXswiss/services",
- "path": "e2e-stack/specs/buy.spec.ts",
- "note": "Widget buy flow: frontend Playwright loads public lists including country."
+ "path": "e2e-stack/specs/kyc.spec.ts",
+ "note": "KYC personal-data step: frontend Playwright fills the country search dropdown (Switzerland)."
}
]
},
{
"method": "GET",
"path": "/v1/language",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"repo": "DFXswiss/packages",
@@ -178,11 +183,11 @@
{
"method": "GET",
"path": "/v1/statistic",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"unidentified": true,
- "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "Prefix on the swagger allowlist. The root and nested paths are served from the background GET cache; a miss is 503 not served, never forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
@@ -195,11 +200,11 @@
{
"method": "GET",
"path": "/v1/coin",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"unidentified": true,
- "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages or DFXswiss/services."
+ "note": "Prefix on the swagger allowlist. The root and nested paths are served from the background GET cache; a miss is 503 not served, never forwarded. No named call site in DFXswiss/packages or DFXswiss/services."
}
],
"e2e": [
@@ -212,24 +217,25 @@
{
"method": "GET",
"path": "/v1/setting",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
- "unidentified": true,
- "note": "Exact list root GET /v1/setting is served from the background cache."
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/settings.hook.ts",
+ "note": "GET SettingsUrl.infoBanner (setting/infoBanner)."
}
],
"e2e": [
{
"unidentified": true,
- "note": "No frontend E2E named that GETs the list root /v1/setting live."
+ "note": "No frontend E2E named that GETs the /v1/setting prefix live. Required before changing this prefix."
}
]
},
{
"method": "GET",
"path": "/v1/bank",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"repo": "DFXswiss/packages",
@@ -247,11 +253,11 @@
{
"method": "GET",
"path": "/v1/app",
- "match": "exact",
+ "match": "prefix",
"usedIn": [
{
"unidentified": true,
- "note": "Exact list root on the swagger allowlist. Served from the background GET cache; a miss is 503 not served. No named call site in DFXswiss/packages."
+ "note": "Prefix on the swagger allowlist. The root and nested paths are served from the background GET cache; a miss is 503 not served, never forwarded. No named call site in DFXswiss/packages."
}
],
"e2e": [
diff --git a/server.js b/server.js
index 60772bb..a7c00a2 100644
--- a/server.js
+++ b/server.js
@@ -77,15 +77,15 @@ try {
}
function cacheKey(req) {
- return req.method + ' ' + (req.url ?? '/').split('?')[0];
+ const method = req.method === 'HEAD' ? 'GET' : req.method;
+ return method + ' ' + (req.url ?? '/').split('?')[0];
}
function isCacheable(req) {
- if (req.method !== 'GET') return false;
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
if (req.headers.authorization) return false;
const path = (req.url ?? '/').split('?')[0];
- if (path === '/' || path === '/version' || path === '/swagger' || path === '/swagger-json') return true;
- return CACHE_PREFIXES.includes(path);
+ return isServedPath(path);
}
function getCached(key) {
@@ -211,24 +211,13 @@ const EXACT_GET_PATHS = [
function isServedPath(path) {
const p = (path ?? '/').split('?')[0];
if (EXACT_GET_PATHS.includes(p)) return true;
- return CACHE_PREFIXES.includes(p);
+ return CACHE_PREFIXES.some((prefix) => p === prefix || p.startsWith(prefix + '/'));
}
function isKnownLocalRequest(req) {
- if (req.method !== 'GET') return false;
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const path = (req.url ?? '/').split('?')[0];
- if (
- path === '/version' ||
- path === '/swagger' ||
- path === '/swagger/' ||
- path === '/swagger-ui' ||
- path === '/swagger-ui/' ||
- path === '/swagger-json' ||
- path === '/swagger-json/'
- ) {
- return true;
- }
- return isCacheable(req);
+ return isServedPath(path);
}
async function refreshSwagger() {
@@ -240,7 +229,7 @@ async function refreshSwagger() {
if (!isServedPath(p)) continue;
paths[p] = ops;
}
- swaggerSpec = { ...got.json, paths, info: { ...(got.json.info || {}), title: 'DFX API' } };
+ swaggerSpec = { ...got.json, paths, info: { ...(got.json.info ?? {}), title: 'DFX API' } };
} catch (err) {
console.error('swagger refresh', err.message);
}
@@ -279,6 +268,10 @@ function sendJson(res, status, body, via, extraHeaders) {
'x-front-api': via,
'access-control-allow-origin': '*',
}, extraHeaders ?? {}));
+ if (res.req && res.req.method === 'HEAD') {
+ res.end();
+ return;
+ }
res.end(buf);
}
@@ -293,7 +286,7 @@ function highlightJson(obj) {
function sendVersion(req, res, obj, via) {
if (!canWrite(res)) return;
- if (String(req.headers.accept || '').includes('text/html')) {
+ if (String(req.headers.accept ?? '').includes('text/html')) {
const html = Buffer.from(
'' +
'