Skip to content

Commit ccd0fa8

Browse files
committed
Graceful shutdown drain, per-request access log, CI workflow, and coverage
Operational hardening: - server.ts: ServerHandle.stop() now returns a Promise and resolves once in-flight requests have drained (server.stop(false)); start.ts awaits it on SIGINT/SIGTERM before tearing down the cache/async store. The fetch handler is wrapped to emit one access-log line per request ("METHOD path status Nms") at info level. - .github/workflows/ci.yml: frozen install + prettier + eslint + tsc + bun test, a `bun pm scan` dependency-audit job, and the Foundry contract tests (there was previously only a docs-deploy workflow). Coverage: - server.test.ts: GET /requests/{id} polling route (pending / complete / failed / unknown / bad-format), 413/415 request-body rejections, stop() resolves → src/server.ts now 100%. - pipeline.test.ts: fetchProofIfEnabled — proof attached on gateway success, proof omitted (non-fatal) on gateway failure, gateway skipped when the endpoint has no responseMatches. - src/cli/commands/{address,generate-mnemonic,start}.test.ts: spawn-based smoke tests (previously only config and identity had CLI tests).
1 parent d078e55 commit ccd0fa8

16 files changed

Lines changed: 483 additions & 94 deletions

File tree

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
node:
10+
name: Lint, typecheck, test
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: oven-sh/setup-bun@v2
15+
- run: bun install --frozen-lockfile
16+
- run: bun run lint:prettier
17+
- run: bun run lint:eslint
18+
- run: bunx tsc --noEmit
19+
- run: bun test
20+
21+
audit:
22+
name: Dependency audit
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: oven-sh/setup-bun@v2
27+
- run: bun install --frozen-lockfile
28+
- run: bun pm scan
29+
30+
contracts:
31+
name: Foundry tests
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
with:
36+
submodules: recursive
37+
- uses: foundry-rs/foundry-toolchain@v1
38+
- run: forge test -vvv
39+
working-directory: contracts

book/docs/concepts/architecture.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ giving plugins the ability to observe, filter, or modify data at each stage.
3232
`X-Api-Key` header, or x402 payment proof (an `X-Payment-Proof` header attesting a confirmed on-chain payment). An
3333
unpaid x402 request gets `402` with the payment parameters.
3434
4. **Validate parameters** -- check that all required parameters (those without `fixed` or `default` values) are present
35-
in the request body. (Async-mode endpoints stop here and return `202` + a `pollUrl`; the rest of the pipeline runs
36-
in the background.)
37-
5. **Check cache** -- if the endpoint has cache config, return a cached response when the TTL has not expired
38-
(sync mode only).
35+
in the request body. (Async-mode endpoints stop here and return `202` + a `pollUrl`; the rest of the pipeline runs in
36+
the background.)
37+
5. **Check cache** -- if the endpoint has cache config, return a cached response when the TTL has not expired (sync mode
38+
only).
3939
6. **Plugin: onBeforeApiCall** -- plugins can modify request parameters before the upstream call.
4040
7. **Call API** -- make the HTTP request to the upstream API via `src/api/call.ts`. Method, path, headers, query
4141
parameters, and body are assembled from the endpoint config and client parameters.

book/docs/config/settings.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ requested. Endpoints without `responseMatches` skip proof generation even when p
7979
## `fhe`
8080

8181
Configures FHE encryption of encoded responses. When set to an object, any endpoint with an
82-
[`encrypt`](/docs/config/apis#encryption-fhe) block has its ABI-encoded value replaced with an FHE ciphertext before signing.
82+
[`encrypt`](/docs/config/apis#encryption-fhe) block has its ABI-encoded value replaced with an FHE ciphertext before
83+
signing.
8384

8485
### No FHE (default)
8586

book/docs/introduction.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,20 @@ Client ──POST──▶ Airnode ──HTTP──▶ Upstream API
6868
4. If the endpoint has encoding configured, Airnode ABI-encodes the response. Otherwise, the raw JSON is returned.
6969
5. If the endpoint has `encrypt` configured, the encoded value is replaced with an FHE ciphertext.
7070
6. Airnode signs the result with the operator's private key.
71-
7. If TLS proofs are enabled, Airnode requests an attestation of the upstream call (non-fatal — a failure just omits the proof).
71+
7. If TLS proofs are enabled, Airnode requests an attestation of the upstream call (non-fatal — a failure just omits the
72+
proof).
7273
8. The signed response is returned to the client.
7374

74-
Endpoints can also run in `async` mode (returns `202` with a `pollUrl`; the result is fetched later from `GET /requests/{requestId}`) or `stream` mode (the signed result is wrapped in a single Server-Sent Events frame). See [Request and Response](/docs/concepts/request-response).
75+
Endpoints can also run in `async` mode (returns `202` with a `pollUrl`; the result is fetched later from
76+
`GET /requests/{requestId}`) or `stream` mode (the signed result is wrapped in a single Server-Sent Events frame). See
77+
[Request and Response](/docs/concepts/request-response).
7578

7679
## Endpoint IDs
7780

78-
Endpoint IDs are deterministic hashes of the API specification -- the URL, path, method, non-secret parameters,
79-
encoding configuration, and encryption configuration. The ID binds the airnode's signature to the exact API spec the
80-
operator committed to, so a consumer hard-coding an endpoint ID locks in the upstream URL, parameters, and encoding
81-
rules. Any change to the spec produces a different ID, which on-chain consumers can detect immediately.
81+
Endpoint IDs are deterministic hashes of the API specification -- the URL, path, method, non-secret parameters, encoding
82+
configuration, and encryption configuration. The ID binds the airnode's signature to the exact API spec the operator
83+
committed to, so a consumer hard-coding an endpoint ID locks in the upstream URL, parameters, and encoding rules. Any
84+
change to the spec produces a different ID, which on-chain consumers can detect immediately.
8285

8386
```
8487
endpointId = keccak256(url | path | method | sorted parameters | encoding spec | encrypt spec)

book/docs/operators/deployment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ docker compose up -d
115115
| Variable | Required | Description |
116116
| --------------------- | -------- | ---------------------------------------------------------------------------------- |
117117
| `AIRNODE_MNEMONIC` | Yes\* | BIP-39 mnemonic. Signs all responses. Takes precedence over `AIRNODE_PRIVATE_KEY`. |
118-
| `AIRNODE_PRIVATE_KEY` | Yes\* | Hex-encoded private key (with `0x` prefix). Signs all responses. |
118+
| `AIRNODE_PRIVATE_KEY` | Yes\* | Hex-encoded private key (with `0x` prefix). Signs all responses. |
119119
| `LOG_FORMAT` | No | `text` (default) or `json`. |
120120
| `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, or `error`. |
121121

book/docs/operators/index.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,12 @@ Expected response:
9999

100100
## Environment variables
101101

102-
| Variable | Required | Description |
103-
| --------------------- | -------- | ------------------------------------------------------------------------------------ |
104-
| `AIRNODE_MNEMONIC` | Yes\* | BIP-39 mnemonic. Signs all responses. Takes precedence over `AIRNODE_PRIVATE_KEY`. |
105-
| `AIRNODE_PRIVATE_KEY` | Yes\* | Hex-encoded private key (with `0x` prefix). Signs all responses. |
106-
| `LOG_FORMAT` | No | `text` (default) or `json`. |
107-
| `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, or `error`. |
102+
| Variable | Required | Description |
103+
| --------------------- | -------- | ---------------------------------------------------------------------------------- |
104+
| `AIRNODE_MNEMONIC` | Yes\* | BIP-39 mnemonic. Signs all responses. Takes precedence over `AIRNODE_PRIVATE_KEY`. |
105+
| `AIRNODE_PRIVATE_KEY` | Yes\* | Hex-encoded private key (with `0x` prefix). Signs all responses. |
106+
| `LOG_FORMAT` | No | `text` (default) or `json`. |
107+
| `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, or `error`. |
108108

109109
\* Exactly one of `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY` is required.
110110

book/docs/plugins.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ alter encoded data, and observe events -- all without modifying the core node.
1212

1313
Six hooks fire at specific points in the pipeline:
1414

15-
| Hook | Type | Can modify | When it fires |
16-
| ----------------- | ----------- | ------------------------ | -------------------------------------- |
17-
| `onHttpRequest` | Mutation | Reject the request | After endpoint resolution, before auth |
18-
| `onBeforeApiCall` | Mutation | Request parameters | Before the upstream API call |
19-
| `onAfterApiCall` | Mutation | Response data and status | After the upstream API responds |
15+
| Hook | Type | Can modify | When it fires |
16+
| ----------------- | ----------- | --------------------------- | ----------------------------------------------------------- |
17+
| `onHttpRequest` | Mutation | Reject the request | After endpoint resolution, before auth |
18+
| `onBeforeApiCall` | Mutation | Request parameters | Before the upstream API call |
19+
| `onAfterApiCall` | Mutation | Response data and status | After the upstream API responds |
2020
| `onBeforeSign` | Mutation | The data about to be signed | After encoding (and FHE encryption, if any), before signing |
21-
| `onResponseSent` | Observation | Nothing (read-only) | After the signed response is sent |
22-
| `onError` | Observation | Nothing (read-only) | When an error occurs at any stage |
21+
| `onResponseSent` | Observation | Nothing (read-only) | After the signed response is sent |
22+
| `onError` | Observation | Nothing (read-only) | When an error occurs at any stage |
2323

24-
For an `encrypt`-configured endpoint, `onBeforeSign` sees the FHE ciphertext (`abi.encode(bytes32 handle, bytes proof)`),
25-
not the plaintext-encoded value. Every hook context also includes a `requestId` (a per-request hex id), in addition to
26-
the fields shown below.
24+
For an `encrypt`-configured endpoint, `onBeforeSign` sees the FHE ciphertext
25+
(`abi.encode(bytes32 handle, bytes proof)`), not the plaintext-encoded value. Every hook context also includes a
26+
`requestId` (a per-request hex id), in addition to the fields shown below.
2727

2828
**Mutation hooks** can change the pipeline. If a mutation hook fails or times out, the request is **dropped** rather
2929
than processed without the plugin's intervention. This prevents data leaks if the plugin exists for security purposes.

book/docs/troubleshooting.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ the API changed its response format, a nested field was renamed, or the path use
150150
**Check the logs.** Airnode logs every request with its endpoint ID, response status, and processing time. Set
151151
`LOG_LEVEL=debug` for detailed pipeline output including upstream request/response details.
152152

153-
**Verify the config.** Run `airnode config validate -c config.yaml` to check your config against the schema before starting the server.
153+
**Verify the config.** Run `airnode config validate -c config.yaml` to check your config against the schema before
154+
starting the server.
154155

155156
**Test the upstream API directly.** Use curl to call the upstream API with the same parameters the airnode would use.
156157
This isolates whether the issue is in the airnode or the upstream.

integration/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async function createTestServer(options: TestServerOptions = {}): Promise<TestCo
8484
endpointMap,
8585
baseUrl: `http://127.0.0.1:${String(server.port)}`,
8686
stop: () => {
87-
server.stop();
87+
void server.stop();
8888
cache.stop();
8989
asyncStore.stop();
9090
},

src/cli/commands/address.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, test } from 'bun:test';
2+
3+
// =============================================================================
4+
// CLI `address` command — spawn-based smoke tests
5+
//
6+
// The address-derivation logic is unit-tested in src/sign.test.ts
7+
// (`accountFromEnv`); these cover the CLI wiring: key/mnemonic in, address out,
8+
// clear error + exit 1 when the key material is missing or malformed.
9+
// =============================================================================
10+
11+
// The anvil account #0 — its private key and mnemonic both derive this address.
12+
const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
13+
const TEST_MNEMONIC = 'test test test test test test test test test test test junk';
14+
const TEST_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
15+
16+
async function runAddress(
17+
env: Record<string, string | undefined>
18+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
19+
const { AIRNODE_PRIVATE_KEY: _k, AIRNODE_MNEMONIC: _m, ...base } = process.env;
20+
const proc = Bun.spawn(['bun', '--env-file', '/dev/null', 'src/cli/index.ts', 'address'], {
21+
env: { ...base, ...env },
22+
stdout: 'pipe',
23+
stderr: 'pipe',
24+
});
25+
const [exitCode, stdout, stderr] = await Promise.all([
26+
proc.exited,
27+
new Response(proc.stdout).text(),
28+
new Response(proc.stderr).text(),
29+
]);
30+
return { exitCode, stdout, stderr };
31+
}
32+
33+
describe('address CLI', () => {
34+
test('derives the address from AIRNODE_PRIVATE_KEY', async () => {
35+
const { exitCode, stdout } = await runAddress({ AIRNODE_PRIVATE_KEY: TEST_PRIVATE_KEY });
36+
expect(exitCode).toBe(0);
37+
expect(stdout).toContain(TEST_ADDRESS);
38+
});
39+
40+
test('derives the address from AIRNODE_MNEMONIC', async () => {
41+
const { exitCode, stdout } = await runAddress({ AIRNODE_MNEMONIC: TEST_MNEMONIC });
42+
expect(exitCode).toBe(0);
43+
expect(stdout).toContain(TEST_ADDRESS);
44+
});
45+
46+
test('exits 1 with a clear error when neither variable is set', async () => {
47+
const { exitCode, stderr } = await runAddress({});
48+
expect(exitCode).toBe(1);
49+
expect(stderr).toContain('AIRNODE_PRIVATE_KEY or AIRNODE_MNEMONIC environment variable is required');
50+
});
51+
52+
test('exits 1 with a clear error on a malformed private key', async () => {
53+
const { exitCode, stderr } = await runAddress({ AIRNODE_PRIVATE_KEY: '0xnothex' });
54+
expect(exitCode).toBe(1);
55+
expect(stderr).toContain('AIRNODE_PRIVATE_KEY must be a 0x-prefixed 32-byte hex string');
56+
});
57+
});

0 commit comments

Comments
 (0)