mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
fix(server): declare the method contract on the three read-only query proxies
/api/opensky, /api/adsblol/mil and /api/route answered any verb with the same body while ignoring the request entirely. Measured against a running server before the change: POST /api/opensky returned 200 with 833,759 bytes of live state vectors, POST /api/adsblol/mil returned 47,372 bytes. Their siblings do not: /api/geocode, /api/weather and /api/transit answer 405, and /api/overpass is deliberately POST-only. These three are read-only query proxies, so they now answer 405 to anything but GET, using the wording and shape /api/geocode already uses. The refusal comes first, before rate limiting and before any upstream call, which the tests assert by stubbing fetch to throw. Scope, stated plainly: this is a consistency and intent fix. No rate limiter was being bypassed — /api/route already limits every request regardless of verb, and the two aircraft routes have no limiter at all, only 9s and 12s server-side caches that bound upstream cost for any method. One existing fixture in src/search/geospatial.test.mjs built a request without a method, which a real Node request always has; it now sets GET. Verified on a running server after the change: GET still 200 on all three, POST 405 on all three, /api/overpass and the other proxies unchanged. Suite goes from 5090 to 5092 tests, 0 failures.
This commit is contained in:
1 parent
e7707d9a0f
commit
b09db55149
7 files changed
+70
-1
No files matched your search
@@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
- `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded.
|
||||||
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
- `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity.
|
||||||
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors.
|
||||||
|
- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call.
|
||||||
- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
- Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI).
|
||||||
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
- Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped.
|
||||||
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
|
- `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message.
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export function adsbLolProxy() {
|
|||||||
|
|
||||||
const installMiddleware = (server) => {
|
const installMiddleware = (server) => {
|
||||||
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||||
|
// Read-only snapshot endpoint; match the method contract its sibling
|
||||||
|
// query proxies already declare instead of serving every verb alike.
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||||
|
|||||||
@@ -350,6 +350,14 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
|||||||
export function openSkyProxy() {
|
export function openSkyProxy() {
|
||||||
const installMiddleware = (server) => {
|
const installMiddleware = (server) => {
|
||||||
server.middlewares.use('/api/opensky', async (req, res) => {
|
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||||
|
// Read-only snapshot endpoint: state the method contract the way
|
||||||
|
// /api/geocode and the other query proxies state it, rather than
|
||||||
|
// answering any verb with the same body and ignoring the request body.
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const requestedMode = normalizeOpenSkyAuthMode(
|
const requestedMode = normalizeOpenSkyAuthMode(
|
||||||
process.env.OPENSKY_AUTH_MODE,
|
process.env.OPENSKY_AUTH_MODE,
|
||||||
|
|||||||
@@ -227,6 +227,13 @@ export function installRouteMiddleware(
|
|||||||
// only shape that asks the upstream for them. A response WITHOUT steps is
|
// only shape that asks the upstream for them. A response WITHOUT steps is
|
||||||
// byte for byte what this endpoint has always returned.
|
// byte for byte what this endpoint has always returned.
|
||||||
middlewares.use('/api/route', async (req, res) => {
|
middlewares.use('/api/route', async (req, res) => {
|
||||||
|
// The documented shape above is a GET with query parameters; say so,
|
||||||
|
// the way the other query proxies do, instead of answering any verb.
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fail = (msg) => {
|
const fail = (msg) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||||
|
|||||||
@@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo
|
|||||||
const request = async (query) => {
|
const request = async (query) => {
|
||||||
let payload;
|
let payload;
|
||||||
await handler(
|
await handler(
|
||||||
{ url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } },
|
{
|
||||||
|
url: `/?${query}`,
|
||||||
|
method: 'GET',
|
||||||
|
socket: { remoteAddress: '127.0.0.1' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
writeHead() {},
|
writeHead() {},
|
||||||
end(body) {
|
end(body) {
|
||||||
|
|||||||
@@ -381,3 +381,26 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
|||||||
assert.equal(res2.statusCode, 502);
|
assert.equal(res2.statusCode, 502);
|
||||||
assert.equal(callCount, 1);
|
assert.equal(callCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => {
|
||||||
|
// Both routes answered every verb with the full snapshot, unlike the query
|
||||||
|
// proxies beside them, so nothing said whether a body was read or ignored.
|
||||||
|
// A stubbed fetch that throws proves the refusal happens before the handler
|
||||||
|
// reaches upstream, rather than after paying for the work and discarding it.
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
throw new Error(`upstream must not be reached: ${url}`);
|
||||||
|
});
|
||||||
|
const cases = [
|
||||||
|
[install(providers.openSkyProxy()), '/api/opensky'],
|
||||||
|
[install(providers.adsbLolProxy()), '/api/adsblol/mil'],
|
||||||
|
];
|
||||||
|
for (const [request, route] of cases) {
|
||||||
|
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
|
||||||
|
const response = await request(route, '/', method);
|
||||||
|
assert.equal(response.statusCode, 405, `${method} ${route}`);
|
||||||
|
assert.deepEqual(JSON.parse(response.body), {
|
||||||
|
error: 'Method Not Allowed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -482,3 +482,22 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
|
|||||||
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
|
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
|
||||||
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
|
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => {
|
||||||
|
// The documented shape is a GET with query parameters, but every verb was
|
||||||
|
// answered alike. Refuse first: a stubbed fetch that throws shows the
|
||||||
|
// rejection costs neither an upstream call nor a rate-limit slot.
|
||||||
|
const request = install(installRouteMiddleware);
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
throw new Error('upstream must not be reached');
|
||||||
|
});
|
||||||
|
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
|
||||||
|
const response = await request(
|
||||||
|
'/api/route',
|
||||||
|
'?profile=car&coords=-97,30;-97.01,30.01',
|
||||||
|
method,
|
||||||
|
);
|
||||||
|
assert.equal(response.statusCode, 405, method);
|
||||||
|
assert.deepEqual(response.body, { error: 'Method Not Allowed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user