`. 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_*`, `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`).
+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
+ 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.
+
+## 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 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. GET/HEAD `/` is local `302` `Location: swagger`
+ and is not a JSON cache root.
+- 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 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.
+- 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).
+
+## 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.
+
+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. 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
+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`.
+Those tests usually live in the consumer repository (for example
+`DFXswiss/app` `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
new file mode 100644
index 0000000..0f2262e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:22-alpine
+WORKDIR /app
+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 8db9cf5..3ce801a 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,72 @@
# 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.
+**Wait-endpoint exception:** API endpoints whose purpose is to wait for an event or status change (including long polling) are exempt from the 100ms response deadline. Their HTTP request may remain open for that wait; it must not be cut off at 100ms or fail review or CI solely because of that duration. This exception applies to the response-deadline rules throughout this document. Every individual wait request must be logged with its actual waiting duration; the response-deadline exception does not remove this documentation duty. All other quality requirements remain applicable. It does not permit forwarding a known endpoint. This is a rule clarification; implementing wait endpoints is separate work.
+
+Public HTTP layer in front of the DFX backend. Listed routes (`GET`/`HEAD /` 302 to swagger, `/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
-`BACKEND_URL` is required.
+After cloning the repository, start the front API and its loopback-only HTTP stub with one command:
+
+```bash
+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:3000 node server.js
+BACKEND_URL=http://127.0.0.1:4000 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).
+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
+
+- `GET`/`HEAD /` — answered locally with 302 `Location: swagger`; HEAD has an empty body
+- `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 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. Nested `GET`/`HEAD /v1/statistic/status` is answered from the cached list-root `status` object. 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 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
+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
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).
+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
new file mode 100644
index 0000000..ae06abe
--- /dev/null
+++ b/REVIEW.md
@@ -0,0 +1,142 @@
+# Review
+
+**Wait-endpoint exception:** API endpoints whose purpose is to wait for an event or status change (including long polling) are exempt from the 100ms response deadline. Their HTTP request may remain open for that wait; it must not be cut off at 100ms or fail review or CI solely because of that duration. This exception applies to the response-deadline rules throughout this document. Every individual wait request must be logged with its actual waiting duration; the response-deadline exception does not remove this documentation duty. All other quality requirements remain applicable. It does not permit forwarding a known endpoint. This is a rule clarification; implementing wait endpoints is separate work.
+
+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. 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 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.
+- `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, 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
+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
+
+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`, 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
+
+[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, 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.
+
+## 12. Known routes: 100ms. Unknown routes: forwarded
+
+This process knows a fixed listed set of local GET/HEAD routes (GET/HEAD `/`
+302 to swagger, 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
+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
new file mode 100644
index 0000000..f2e901d
--- /dev/null
+++ b/offered-routes.json
@@ -0,0 +1,271 @@
+{
+ "title": "Offered routes: usage and frontend E2E",
+ "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",
+ "path": "/",
+ "match": "exact",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "note": "isServedPath: GET / is answered locally with 302 Location swagger. It is not a JSON cache root and is never forwarded. No dedicated public frontend call site named."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that asserts GET / of this process. This process answers GET / with 302 to swagger from local state."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/version",
+ "match": "exact",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/app",
+ "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": "GET",
+ "path": "/v1/asset",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/asset.hook.ts",
+ "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/app",
+ "path": "src/screens/buy.screen.tsx",
+ "note": "Widget buy screen loads assets through @dfx.swiss/react."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/app",
+ "path": "e2e-stack/specs/buy.spec.ts",
+ "note": "Widget buy flow: frontend Playwright asserts against live GET /v1/asset."
+ },
+ {
+ "repo": "DFXswiss/app",
+ "path": "e2e-stack/specs/smoke.spec.ts",
+ "note": "Smoke fetch of GET /v1/asset from the running stack."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/fiat",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/fiat.hook.ts",
+ "note": "GET FiatUrl.get (fiat). Served from the background GET cache; a miss is 503 not served, never forwarded."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_fiat_service.dart",
+ "note": "Wallet GET /v1/fiat."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/app",
+ "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). Served from the background GET cache or optional Postgres; a miss is 503 not served, never forwarded."
+ },
+ {
+ "repo": "RealUnitCH/app",
+ "path": "lib/packages/service/dfx/dfx_country_service.dart",
+ "note": "Wallet GET /v1/country."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/app",
+ "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": "prefix",
+ "usedIn": [
+ {
+ "repo": "DFXswiss/packages",
+ "path": "packages/react/src/hooks/language.hook.ts",
+ "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",
+ "path": "lib/packages/service/dfx/dfx_language_service.dart",
+ "note": "Wallet GET /v1/language."
+ }
+ ],
+ "e2e": [
+ {
+ "repo": "DFXswiss/app",
+ "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": "Prefix on the swagger allowlist. The root is served from the background GET cache. Nested GET /v1/statistic/status is answered from that cached list-root status object (not a separate backend fetch). A miss is 503 not served, never forwarded. No named call site in DFXswiss/packages or DFXswiss/app."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/statistic live. Required before changing this list root."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/coin",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "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/app."
+ }
+ ],
+ "e2e": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/coin live. Required before changing this list root."
+ }
+ ]
+ },
+ {
+ "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": "No frontend E2E named that GETs the /v1/setting prefix live. 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). 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 list root."
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v1/app",
+ "match": "prefix",
+ "usedIn": [
+ {
+ "unidentified": true,
+ "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": [
+ {
+ "unidentified": true,
+ "note": "No frontend E2E named that hits /v1/app live. Required before changing this list root."
+ }
+ ]
+ }
+ ]
+}
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
new file mode 100644
index 0000000..53d4115
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "dfx-front-api",
+ "private": true,
+ "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"
+ },
+ "dependencies": {
+ "pg": "^8.16.3"
+ },
+ "devDependencies": {
+ "c8": "^10.1.3"
+ }
+}
diff --git a/scripts/local-backend.js b/scripts/local-backend.js
new file mode 100644
index 0000000..b5fe9fd
--- /dev/null
+++ b/scripts/local-backend.js
@@ -0,0 +1,168 @@
+'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: {} };
+
+ 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;
+ }
+ }
+
+ 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/server.js b/server.js
new file mode 100644
index 0000000..df0c9a7
--- /dev/null
+++ b/server.js
@@ -0,0 +1,651 @@
+'use strict';
+
+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 = +(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, 300000));
+const CACHE_MAX = +(orFallback(process.env.CACHE_MAX, 500));
+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 list roots this layer may answer from cache. Authenticated
+// requests are never cached.
+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();
+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,
+ connectionTimeoutMillis: 90,
+ });
+ pool.on('error', (err) => console.error('pg pool', err.message));
+ attachPoolGuards(pool);
+ }
+} catch (err) {
+ console.error('pg init failed:', err.message);
+ process.exit(1);
+}
+
+function cacheKey(req) {
+ const method = req.method === 'HEAD' ? 'GET' : req.method;
+ return method + ' ' + (req.url ?? '/').split('?')[0];
+}
+
+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 === '/') return false;
+ return isServedPath(path);
+}
+
+function getCached(key) {
+ return cache.get(key) || null;
+}
+
+function embeddedStatisticStatus(body) {
+ try {
+ const raw = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
+ const json = JSON.parse(raw);
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
+ const nested = json.status;
+ if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return null;
+ return nested;
+ } catch {
+ return 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 });
+ if (key !== 'GET /v1/statistic' || status !== 200) return;
+ const nested = embeddedStatisticStatus(body);
+ if (!nested) {
+ cache.delete('GET /v1/statistic/status');
+ return;
+ }
+ putCache('GET /v1/statistic/status', 200, headers, Buffer.from(JSON.stringify(nested)));
+}
+
+function localVersion() {
+ return { commit: 'front-api', startedAt: STARTED };
+}
+
+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 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 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);
+ const req = http.request(
+ {
+ hostname: target.hostname,
+ port: backendPortFor(target),
+ path: urlPath,
+ method: 'GET',
+ },
+ (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, REQUEST_TIMEOUT_MS, () => {
+ req.destroy();
+ reject(new Error('timeout'));
+ });
+ req.end();
+ });
+}
+
+const EXACT_GET_PATHS = [
+ '/',
+ '/version',
+ '/swagger',
+ '/swagger/',
+ '/swagger-json',
+ '/swagger-json/',
+ '/swagger-ui',
+ '/swagger-ui/',
+];
+
+function isServedPath(path) {
+ const p = (path ?? '/').split('?')[0];
+ if (EXACT_GET_PATHS.includes(p)) return true;
+ return CACHE_PREFIXES.some((prefix) => p === prefix || p.startsWith(prefix + '/'));
+}
+
+function isKnownLocalRequest(req) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
+ const path = (req.url ?? '/').split('?')[0];
+ return isServedPath(path);
+}
+
+async function refreshSwagger() {
+ try {
+ 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)) {
+ if (!isServedPath(p)) continue;
+ paths[p] = ops;
+ }
+ swaggerSpec = { ...got.json, paths, info: { ...(got.json.info ?? {}), title: 'DFX API' } };
+ } catch (err) {
+ console.error('swagger refresh', err.message);
+ }
+}
+
+function swaggerHtml() {
+ return `
+DFX API
+
+
+
+
+
+
+`;
+}
+
+function sendJson(res, status, body, via, extraHeaders) {
+ if (!canWrite(res)) return;
+ 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, 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 ?? {}));
+ if (res.req && res.req.method === 'HEAD') {
+ res.end();
+ return;
+ }
+ 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 (!canWrite(res)) return;
+ 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,
+ });
+ if (req.method === 'HEAD') {
+ res.end();
+ return;
+ }
+ 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);
+ if (!result || !result.rows) return null;
+ 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 ?? '/',
+ 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', () => {
+ if (!canWrite(res)) return;
+ const body = Buffer.concat(chunks);
+ const headers = { ...up.headers };
+ delete headers['transfer-encoding'];
+ res.writeHead(up.statusCode, headers);
+ res.end(body);
+ });
+ });
+ p.on('error', (err) => {
+ console.error('proxy error', err.message);
+ 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 }));
+ });
+ res.on('finish', () => p.destroy());
+ res.on('close', () => p.destroy());
+ req.on('aborted', () => p.destroy());
+ req.pipe(p);
+}
+
+function cacheRefreshPaths() {
+ const roots = [...CACHE_PREFIXES];
+ const paths = swaggerSpec?.paths;
+ if (!paths) return roots;
+ return [...new Set([...roots, ...Object.keys(paths).filter((path) => isServedPath(path) && path !== '/')])];
+}
+
+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];
+ if (path === '/version') {
+ sendVersion(req, res, localVersion(), 'local');
+ return;
+ }
+
+ if (path === '/') {
+ if (!canWrite(res)) return;
+ res.writeHead(302, {
+ location: 'swagger',
+ 'x-front-api': 'local',
+ 'access-control-allow-origin': '*',
+ });
+ res.end();
+ return;
+ }
+
+ if (path === '/swagger' || path === '/swagger/' || path === '/swagger-ui' || path === '/swagger-ui/') {
+ if (!swaggerSpec) {
+ sendJson(res, 503, { statusCode: 503, message: 'swagger snapshot empty' }, 'local');
+ 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' });
+ if (req.method === 'HEAD') {
+ res.end();
+ return;
+ }
+ res.end(html);
+ return;
+ }
+
+ if (path === '/swagger-json' || path === '/swagger-json/') {
+ if (!swaggerSpec) {
+ sendJson(res, 503, { statusCode: 503, message: 'swagger snapshot empty' }, 'local');
+ return;
+ }
+ sendJson(res, 200, swaggerSpec, 'local');
+ return;
+ }
+
+ const cacheable = isCacheable(req);
+ const key = cacheKey(req);
+ if (cacheable) {
+ const hit = getCached(key);
+ if (hit && Date.now() <= hit.exp) {
+ if (!canWrite(res)) return;
+ const headers = { ...hit.headers, 'content-length': hit.body.length, 'x-front-api': 'hit' };
+ res.writeHead(hit.status, headers);
+ if (req.method === 'HEAD') {
+ res.end();
+ return;
+ }
+ res.end(hit.body);
+ return;
+ }
+ }
+
+ if (pool && DB_READ[path]) {
+ tryDbRead(path)
+ .then((body) => {
+ if (!body) {
+ rejectUnserved(res);
+ return;
+ }
+ if (cacheable) {
+ 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);
+ rejectUnserved(res);
+ });
+ return;
+ }
+
+ rejectUnserved(res);
+});
+
+server.on('upgrade', (req, socket, head) => {
+ if (isKnownLocalRequest(req)) {
+ if (!socket.destroyed) socket.destroy();
+ return;
+ }
+ 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)) {
+ 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());
+ 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().then(() => refreshCache());
+ setInterval(refreshSwagger, 10 * 60 * 1000).unref();
+ setInterval(refreshCache, 60 * 1000).unref();
+ });
+}
+
+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,
+ CACHE_MAX,
+ CACHE_PREFIXES,
+ EXACT_GET_PATHS,
+ cache,
+ isServedPath,
+ isKnownLocalRequest,
+ isCacheable,
+ cacheKey,
+ refreshSwagger,
+ refreshCache,
+ cacheRefreshPaths,
+ rejectUnserved,
+ proxy,
+ swaggerHtml,
+ countryDto,
+ languageDto,
+ tryDbRead,
+ putCache,
+ getCached,
+ highlightJson,
+ localVersion,
+ sendJson,
+ sendVersion,
+ attachRequestTimeout,
+ attachResponseBudget,
+ attachPoolGuards,
+ onPoolConnect,
+ canWrite,
+ logDeadlineError,
+ MAX_RESPONSE_MS,
+ outboundTimeoutMs,
+ setSwaggerSpec,
+ getSwaggerSpec,
+ setPool,
+ getPool,
+ boot,
+ maybeExitAfterBoot,
+ REQUEST_TIMEOUT_MS,
+ server,
+};
diff --git a/test/local-start.test.js b/test/local-start.test.js
new file mode 100644
index 0000000..86e9517
--- /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 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, 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 {
+ 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(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(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}`);
+ }
+ } 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/offered-routes.test.js b/test/offered-routes.test.js
new file mode 100644
index 0000000..de283b5
--- /dev/null
+++ b/test/offered-routes.test.js
@@ -0,0 +1,107 @@
+'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, EXACT_GET_PATHS, isServedPath } = require('../server.js');
+
+const PUBLIC_REPOS = new Set([
+ 'DFXswiss/app',
+ 'DFXswiss/packages',
+ 'DFXswiss/dfx-wallet',
+ 'RealUnitCH/app',
+]);
+const METHODS = new Set(['GET']);
+
+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);
+ if (row.match === 'prefix' && !isServedPath(row.path + '/x')) {
+ fail('catalog prefix subpath is not served: ' + row.path + '/x');
+ }
+ 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') {
+ 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 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 must be catalogued as served: ' + p + '/x');
+}
+
+const expectedKeys = new Set();
+for (const p of CACHE_PREFIXES) expectedKeys.add('GET ' + 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/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/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..f8a4eb3
--- /dev/null
+++ b/test/server.test.js
@@ -0,0 +1,1198 @@
+'use strict';
+
+const http = require('http');
+const net = require('net');
+const path = require('path');
+const { Readable } = require('stream');
+const { EventEmitter } = require('events');
+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, 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(
+ {
+ 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', () => {
+ const ms = Date.now() - t0;
+ if (limit > 0 && ms > limit) {
+ reject(new Error('slow ' + method + ' ' + urlPath + ' ' + ms + 'ms'));
+ return;
+ }
+ resolve({
+ status: res.statusCode,
+ body: Buffer.concat(chunks).toString('utf8'),
+ headers: res.headers,
+ ms,
+ });
+ });
+ },
+ );
+ req.on('error', reject);
+ if (payload) req.write(payload);
+ req.end();
+ });
+}
+
+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,
+ status: 0,
+ headers: null,
+ body: null,
+ writeHead(status, headers) {
+ this.status = status;
+ this.headers = headers;
+ this.headersSent = true;
+ },
+ end(body) {
+ this.body = body;
+ },
+ on() {},
+ emit() {},
+ };
+}
+
+function jsonHandler(routes, seen) {
+ return (req, res) => {
+ 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);
+ });
+ };
+}
+
+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 swagger = {
+ paths: {
+ '/v1/asset': { get: {} },
+ '/v1/asset/x': { get: {} },
+ '/v1/asset/{id}': { get: {} },
+ '/v1/setting/infoBanner': { get: {} },
+ '/v1/other': { get: {} },
+ '/version': { get: {} },
+ '/v1/bank': { post: {} },
+ },
+ };
+
+ const seen = [];
+ const backend = http.createServer(
+ jsonHandler({
+ '/v1/asset': assets,
+ '/v1/fiat': fiats,
+ '/': { root: 1 },
+ '/swagger-json': swagger,
+ '/v1/statistic': {
+ totalVolume: { buy: 1, sell: 2 },
+ totalRewards: { staking: 0, ref: 0 },
+ status: { buy: 'ONLINE', sell: 'ONLINE' },
+ },
+ '/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/other': { other: 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_MAX;
+ delete process.env.SQL_HOST;
+ delete process.env.QUOTE_BOOK_REFRESH;
+
+ const s = require(serverJs);
+ const {
+ CACHE_MAX,
+ CACHE_PREFIXES,
+ cache,
+ isServedPath,
+ isKnownLocalRequest,
+ isCacheable,
+ cacheKey,
+ refreshSwagger,
+ swaggerHtml,
+ countryDto,
+ languageDto,
+ tryDbRead,
+ putCache,
+ getCached,
+ highlightJson,
+ localVersion,
+ sendJson,
+ sendVersion,
+ attachRequestTimeout,
+ attachResponseBudget,
+ attachPoolGuards,
+ refreshCache,
+ cacheRefreshPaths,
+ rejectUnserved,
+ proxy,
+ onPoolConnect,
+ canWrite,
+ MAX_RESPONSE_MS,
+ outboundTimeoutMs,
+ setSwaggerSpec,
+ getSwaggerSpec,
+ setPool,
+ getPool,
+ boot,
+ maybeExitAfterBoot,
+ orFallback,
+ backendPortFor,
+ REQUEST_TIMEOUT_MS,
+ server,
+ } = 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 (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 (!(CACHE_MAX > 0)) fail('constants');
+ if (!CACHE_PREFIXES.includes('/v1/asset')) fail('CACHE_PREFIXES');
+ if (getPool() !== null) fail('pool default');
+
+ if (!isServedPath('/version') || !isServedPath('/swagger/') || !isServedPath('/swagger-json/')) fail('isServedPath meta');
+ if (!isServedPath('/swagger-ui') || !isServedPath('/swagger-ui/')) fail('isServedPath ui');
+ if (isServedPath('/v1/other')) fail('isServedPath outside allowlist');
+ if (!isServedPath('/v1/asset') || !isServedPath(undefined)) fail('isServedPath');
+ if (!isServedPath('/v1/asset/1')) fail('isServedPath nested asset');
+ if (!isServedPath('/v1/setting/infoBanner')) fail('isServedPath nested setting');
+ if (!isServedPath('/v1/statistic/status')) fail('isServedPath nested statistic status');
+ if (!isServedPath('/v1/asset/{id}')) fail('isServedPath template');
+ if (isServedPath('/v1/assetfoo')) fail('isServedPath prefix boundary');
+ if (isServedPath('/v1/other')) fail('isServedPath outside listed prefixes');
+ 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/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('known auth GET');
+ if (!isKnownLocalRequest({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('known HEAD');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/v1/asset/1', headers: {} })) fail('known asset id');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('known infoBanner');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/v1/statistic/status', headers: {} })) fail('known statistic status');
+ if (!isKnownLocalRequest({ method: 'HEAD', url: '/v1/statistic/status', headers: {} })) fail('known statistic status HEAD');
+ 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: '/', headers: {} })) fail('GET / is not JSON cache');
+ if (isCacheable({ method: 'HEAD', url: '/', headers: {} })) fail('HEAD / is not JSON cache');
+ if (isCacheable({ method: 'GET', url: undefined, headers: {} })) fail('undefined url GET / not cacheable');
+ if (!isCacheable({ method: 'HEAD', url: '/v1/asset', headers: {} })) fail('cache HEAD asset');
+ 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/other', headers: {} })) fail('cache outside allowlist');
+ if (cacheKey({ method: 'GET', url: '/a' }) !== 'GET /a') fail('cacheKey');
+ if (cacheKey({ method: 'HEAD', url: '/v1/asset' }) !== 'GET /v1/asset') fail('cacheKey HEAD shares GET');
+ 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');
+ 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');
+
+ cache.clear();
+ putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('{"ok":1}'));
+ if (getCached('GET /v1/statistic/status')) fail('statistic without status must not fan-out');
+ putCache('GET /v1/statistic', 200, { 'content-type': 'application/json' }, Buffer.from('not-json'));
+ if (getCached('GET /v1/statistic/status')) fail('invalid statistic json must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('[]'));
+ if (getCached('GET /v1/statistic/status')) fail('array statistic must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('null'));
+ if (getCached('GET /v1/statistic/status')) fail('null statistic must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":null}'));
+ if (getCached('GET /v1/statistic/status')) fail('null status must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":[]}'));
+ if (getCached('GET /v1/statistic/status')) fail('array status must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"status":"ONLINE"}'));
+ if (getCached('GET /v1/statistic/status')) fail('string status must not fan-out');
+ putCache('GET /v1/statistic', 500, { h: '1' }, Buffer.from('{"status":{"buy":"ONLINE"}}'));
+ if (getCached('GET /v1/statistic/status')) fail('non-200 statistic must not fan-out');
+ putCache('GET /v1/asset', 200, { h: '1' }, Buffer.from('{"status":{"buy":"ONLINE"}}'));
+ if (getCached('GET /v1/statistic/status')) fail('non-statistic must not fan-out');
+ putCache('GET /v1/statistic', 200, { h: '1' }, '{"status":{"buy":"ONLINE","sell":"ONLINE"}}');
+ const fromString = getCached('GET /v1/statistic/status');
+ if (!fromString || fromString.status !== 200) fail('string statistic body must fan-out');
+ cache.delete('GET /v1/statistic/status');
+ const statusHeaders = { 'content-type': 'application/json', 'access-control-allow-origin': '*' };
+ putCache(
+ 'GET /v1/statistic',
+ 200,
+ statusHeaders,
+ Buffer.from(
+ JSON.stringify({
+ totalVolume: { buy: 1, sell: 2 },
+ totalRewards: { staking: 0, ref: 0 },
+ status: { buy: 'ONLINE', sell: 'ONLINE' },
+ }),
+ ),
+ );
+ const nested = getCached('GET /v1/statistic/status');
+ if (!nested || nested.status !== 200) fail('statistic status fan-out');
+ const nestedBody = Buffer.isBuffer(nested.body) ? nested.body.toString('utf8') : String(nested.body);
+ if (nestedBody.indexOf('buy":"ONLINE') < 0 || nestedBody.indexOf('sell":"ONLINE') < 0) {
+ fail('statistic status body');
+ }
+ putCache('GET /v1/statistic', 200, { h: '1' }, Buffer.from('{"ok":1}'));
+ if (getCached('GET /v1/statistic/status')) fail('replacing statistic without status must drop fan-out');
+
+ 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);
+
+ setSwaggerSpec(null);
+ const rootOnlyRefreshPaths = cacheRefreshPaths();
+ if (rootOnlyRefreshPaths.includes('/v1/setting/infoBanner')) fail('cacheRefreshPaths null snapshot must use roots only');
+ if (rootOnlyRefreshPaths.includes('/')) fail('cacheRefreshPaths null snapshot must not include GET /');
+ await refreshSwagger();
+ if (!isCacheable({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('nested swagger GET is cacheable');
+ if (!isKnownLocalRequest({ method: 'GET', url: '/v1/setting/infoBanner', headers: {} })) fail('infoBanner is listed');
+ 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 keep listed nested paths and templates');
+ }
+ const refreshPaths = cacheRefreshPaths();
+ if (refreshPaths.includes('/')) fail('cacheRefreshPaths must not include GET /');
+ if (!refreshPaths.includes('/v1/asset')) fail('cacheRefreshPaths roots');
+ if (!refreshPaths.includes('/v1/setting/infoBanner')) fail('cacheRefreshPaths nested');
+ if (!refreshPaths.includes('/version')) fail('cacheRefreshPaths listed swagger path');
+
+ 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);
+ server.emit('request', mkReq('/'), blocked);
+ putCache('GET /v1/asset', 200, { 'content-type': 'application/json' }, Buffer.from('[]'));
+ server.emit('request', mkReq('/v1/asset'), blocked);
+ rejectUnserved(blocked);
+ const raceRes = fakeRes();
+ rejectUnserved(raceRes);
+ proxy(
+ new Readable({
+ read() {
+ this.push(null);
+ },
+ }),
+ raceRes,
+ );
+ const livePipe = new Readable({
+ read() {
+ this.push(null);
+ },
+ });
+ livePipe.method = 'GET';
+ livePipe.url = '/v1/other';
+ 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);
+ },
+ });
+ racedReq.method = 'GET';
+ racedReq.url = '/v1/other';
+ 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/other';
+ 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);
+
+ 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');
+ const assetIdBackendRequests = seen.filter((row) => row.method === 'GET' && row.path === '/v1/asset/1').length;
+ got = await request(port, 'GET', '/v1/asset/1');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('parameterized GET must be local miss');
+ if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/asset/1').length !== assetIdBackendRequests) {
+ fail('parameterized GET must not be forwarded');
+ }
+ const statusMissSeen = seen.filter((row) => row.method === 'GET' && row.path === '/v1/statistic/status').length;
+ cache.delete('GET /v1/statistic/status');
+ got = await request(port, 'GET', '/v1/statistic/status');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('statistic status miss must be local');
+ if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/statistic/status').length !== statusMissSeen) {
+ fail('statistic status must not be forwarded');
+ }
+ cache.delete('GET /v1/setting/infoBanner');
+ const bannerBackendRequests = seen.filter((row) => row.method === 'GET' && row.path === '/v1/setting/infoBanner').length;
+ got = await request(port, 'GET', '/v1/setting/infoBanner');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('nested swagger GET before fill must be local miss');
+ if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/setting/infoBanner').length !== bannerBackendRequests) {
+ fail('nested swagger GET before fill must not 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');
+
+ setSwaggerSpec(null);
+ got = await request(port, 'GET', '/swagger-json');
+ if (got.status !== 503) fail('swagger empty json');
+ got = await request(port, 'HEAD', '/swagger-json');
+ if (got.status !== 503 || got.body !== '' || got.headers['x-front-api'] !== 'local') fail('swagger empty HEAD');
+ 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, 'HEAD', '/swagger-json');
+ if (got.status !== 200 || got.body !== '' || !(+got.headers['content-length'] > 0)) fail('swagger json HEAD');
+ got = await request(port, 'GET', '/swagger/');
+ if (got.status !== 200) fail('swagger html');
+ got = await request(port, 'HEAD', '/swagger');
+ if (got.status !== 200 || got.body !== '' || !(+got.headers['content-length'] > 0)) fail('swagger html HEAD');
+ 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');
+ got = await request(port, 'HEAD', '/version', undefined, { accept: 'text/html' });
+ if (got.status !== 200 || got.body !== '' || !(+got.headers['content-length'] > 0)) fail('version html HEAD');
+
+ cache.clear();
+ got = await request(port, 'HEAD', '/v1/asset');
+ if (got.status !== 503 || got.body !== '' || got.headers['x-front-api'] !== 'local') fail('HEAD cache miss must be local');
+ got = await request(port, 'GET', '/v1/statistic');
+ 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('cache hit');
+ got = await request(port, 'GET', '/v1/statistic/status');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('ok:1 statistic must not serve status');
+
+ 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');
+ cache.delete('GET /v1/country');
+ got = await request(port, 'GET', '/v1/country', undefined, { authorization: 'Bearer x' });
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'db') fail('auth db country');
+ if (getCached('GET /v1/country')) fail('auth db country must not fill public cache');
+ setPool({ query: async () => null });
+ got = await request(port, 'GET', '/v1/language');
+ 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({
+ query: async () => {
+ throw new Error('db down');
+ },
+ });
+ got = await request(port, 'GET', '/v1/language');
+ 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');
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('db catch must not proxy');
+ setPool(null);
+ const authBackendRequests = seen.filter((row) => row.method === 'GET' && row.path === '/v1/country').length;
+ got = await request(port, 'GET', '/v1/country', undefined, { authorization: 'Bearer x' });
+ if (got.status !== 503 || got.body.indexOf('not served') < 0) fail('auth GET must be local miss');
+ if (seen.filter((row) => row.method === 'GET' && row.path === '/v1/country').length !== authBackendRequests) {
+ fail('auth GET must not be forwarded');
+ }
+ putCache('GET /v1/asset', 200, { 'content-type': 'application/json' }, Buffer.from('{"public":true}'));
+ got = await request(port, 'GET', '/v1/asset', undefined, { authorization: 'Bearer x' });
+ if (got.status !== 503 || got.headers['x-front-api'] === 'hit' || got.body.includes('public')) {
+ fail('auth GET must not read public cache');
+ }
+
+ 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);
+ });
+
+ const listedUp = new net.Socket();
+ const listedUpConnects = seen.length;
+ server.emit(
+ 'upgrade',
+ { method: 'GET', url: '/v1/asset', httpVersion: '1.1', headers: {} },
+ listedUp,
+ Buffer.alloc(0),
+ );
+ await sleep(40);
+ if (!listedUp.destroyed) fail('listed upgrade must close locally');
+ if (seen.length !== listedUpConnects) fail('listed upgrade must not reach the backend');
+
+ const upClient = new net.Socket();
+ server.emit(
+ 'upgrade',
+ {
+ method: 'GET',
+ url: '/socket',
+ httpVersion: '1.1',
+ headers: { host: '127.0.0.1', 'x-empty': undefined, 'x-list': ['a', 'b'] },
+ },
+ upClient,
+ Buffer.from('extra'),
+ );
+ 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;
+ rejectUnserved(sent);
+ 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 refreshSwagger();
+ await refreshCache();
+ if (getCached('GET /v1/app')) fail('refreshCache must skip non-200');
+ if (getCached('GET /')) fail('refreshCache must not fill GET /');
+ if (!getCached('GET /v1/setting/infoBanner')) fail('refreshCache must fill listed nested swagger GET');
+ if (!getCached('GET /v1/statistic/status')) fail('refreshCache must fan-out statistic status');
+ if (getCached('GET /v1/other')) fail('refreshCache must not fill a path outside the allowlist');
+ got = await request(port, 'GET', '/');
+ if (got.status !== 302) fail('GET / must 302');
+ if (got.headers.location !== 'swagger') fail('GET / Location swagger');
+ if (got.headers['x-front-api'] !== 'local') fail('GET / x-front-api local');
+ if (got.body.indexOf('not served') >= 0) fail('GET / must not be not served');
+ got = await request(port, 'HEAD', '/', undefined, undefined, 0);
+ if (got.status !== 302) fail('HEAD / must 302');
+ if (got.headers.location !== 'swagger') fail('HEAD / Location swagger');
+ if (got.body) fail('HEAD / must have empty body');
+ got = await request(port, 'GET', '/v1/setting/infoBanner');
+ if (got.status !== 200 || got.body.indexOf('banner') < 0 || got.headers['x-front-api'] !== 'hit') {
+ fail('nested swagger GET must use background cache');
+ }
+ got = await request(port, 'GET', '/v1/statistic/status');
+ if (got.status !== 200 || got.body.indexOf('buy":"ONLINE') < 0 || got.body.indexOf('sell":"ONLINE') < 0) {
+ fail('statistic status from list root');
+ }
+ if (got.headers['x-front-api'] !== 'hit') fail('statistic status cache hit');
+ got = await request(port, 'HEAD', '/v1/statistic/status');
+ if (got.status !== 200 || got.body !== '' || got.headers['x-front-api'] !== 'hit') {
+ fail('HEAD statistic status must share GET cache');
+ }
+ 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');
+ if (got.status !== 200 || got.headers['x-front-api'] !== 'hit') fail('HEAD must share GET cache');
+ if (got.body !== '') fail('HEAD cache hit must have empty body');
+ 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');
+ rejectUnserved(fakeRes());
+ const blockedProxy = fakeRes();
+ blockedProxy.headersSent = true;
+ 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({
+ read() {
+ this.push(null);
+ },
+ });
+ lateReq.method = 'GET';
+ lateReq.url = '/v1/other';
+ lateReq.headers = {};
+ proxy(lateReq, late);
+ late.headersSent = true;
+ late.writableEnded = true;
+ await sleep(50);
+ 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 || 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 || 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));
+ await new Promise((resolve, reject) => {
+ hang.listen(bPort, '127.0.0.1', resolve);
+ hang.on('error', reject);
+ });
+ got = await request(port, 'GET', '/v1/coin');
+ 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);
+
+ 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 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);
+
+ 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 { 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');
+
+ 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 { 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],
+ {
+ 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);
+ boot();
+ });
+ await close(server);
+ await new Promise((resolve, reject) => {
+ server.once('listening', resolve);
+ server.once('error', reject);
+ 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' }),
+ });
+ let out = '';
+ const done = () => {
+ child.kill('SIGTERM');
+ resolve(out);
+ };
+ child.stdout.on('data', (d) => {
+ out += d;
+ if (out.indexOf('listening') >= 0) done();
+ });
+ child.stderr.on('data', (d) => {
+ out += d;
+ });
+ child.on('error', reject);
+ setTimeout(() => {
+ child.kill('SIGKILL');
+ resolve(out);
+ }, 4000);
+ });
+ if (listenOff.indexOf('listening') < 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',
+ 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-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"
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
new file mode 100755
index 0000000..b96c215
--- /dev/null
+++ b/test/test-server.sh
@@ -0,0 +1,146 @@
+#!/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
+# 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 special-case book / stale cache quotes_gone
+# known GET ≤ 100ms; unknown is forwarded max_response_100
+# known miss is 503 not served known_local
+# 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
+ exit 1
+}
+
+[ -f "$server_js" ] || fail "missing: $server_js"
+[ -f "$test_js" ] || fail "missing: $test_js"
+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
+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"
+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"
+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 '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 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"
+if ! awk '/server.on\('\''upgrade'\''/,/^}\);$/' "$server_js" | grep -q 'isKnownLocalRequest'; then
+ fail "known_local: listed upgrades must not be tunnelled"
+fi
+grep -Fq 'ERROR response exceeded' "$server_js" || fail "max_response_100: production must ERROR-log a deadline miss"
+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 -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 "GET /v1/statistic/status" "$server_js" || fail "known_local: statistic status must fan-out from the list root"
+grep -q 'function embeddedStatisticStatus' "$server_js" || fail "known_local: statistic status fan-out helper missing"
+grep -Fq "location: 'swagger'" "$server_js" || fail "known_local: GET / must 302 to swagger"
+grep -Fq 'const roots = [...CACHE_PREFIXES];' "$server_js" || fail "known_local: background refresh must not include GET /"
+grep -q 'function cacheRefreshPaths' "$server_js" || fail "known_local: GET cache refresh set must include roots and listed swagger paths"
+grep -Fq "req.method !== 'GET' && req.method !== 'HEAD'" "$server_js" || fail "known_local: GET and HEAD must be listed and cacheable"
+grep -Fq "startsWith(prefix + '/')" "$server_js" || fail "known_local: CACHE_PREFIXES must list nested paths"
+if grep -Fq 'return isCacheable(req)' "$server_js"; then
+ fail "known_local: Authorization must not make a listed request unknown"
+fi
+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 -Fq 'never forwarded' "$repo_root/README.md" || fail "known_local: README must say listed routes are never forwarded"
+grep -Fq 'must **never wait** on `BACKEND_URL`' "$repo_root/CONTRIBUTING.md" || fail "known_local: listed requests must never wait on BACKEND_URL"
+grep -Fq 'Forwarding a listed route is a hard fail' "$repo_root/REVIEW.md" || fail "known_local: REVIEW must fail listed forwarding"
+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"
+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
+fi
+
+npx c8 --reporter=text --reporter=text-summary node test/server.test.js || fail "server tests failed"
+
+bash "$repo_root/test/run-main-coverage.sh" || fail "require.main coverage run failed"
+
+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"