Merge remote-tracking branch 'upstream/main' into i18n-spanish

This commit is contained in:
Meir Michanie committed 2026-10-16 17:03:26 +02:00
commit 0b58265cee
14 files changed
+575 -6

No files matched your search

+2 -1
View File
@@ -304,7 +304,8 @@ export const DATA_CREDITS = [
key: 'gbfs',
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
},
{
key: 'urbansharing-gbfs',
+152
View File
@@ -0,0 +1,152 @@
// GBFS PROXY — the frequency the allowlist never bounded.
//
// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a
// station_information/station_status path, a 12 s timeout, a 5 MB body cap and
// no redirects. Nothing said HOW OFTEN, so the dev server would relay an
// unbounded stream of requests at a third-party bikeshare operator on any
// caller's behalf — one outbound fetch and one timeout timer each.
//
// These cases drive the real mounted middleware. `fetch` is stubbed per test
// and restored afterwards, so a case that reaches the network fails loudly,
// and the stub's call count is what proves a refused request costs nothing.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js';
const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
const STATION_BODY = '{"data":{"stations":[]}}';
/** Mount the proxy the way the dev server does; returns its route handler. */
function mount(options) {
const routes = new Map();
gbfsProxy(options).configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return routes.get('/api/gbfs');
}
function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) {
return new Promise((resolve, reject) => {
const req = {
method: method || 'GET',
// connect strips the mount prefix before the handler sees the URL.
url: '/' + encodeURIComponent(target),
headers: {},
socket: remoteAddress ? { remoteAddress } : undefined,
};
let status = 200;
let headers = {};
const res = {
writeHead(code, sent) {
status = code;
headers = sent || {};
return res;
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({ status, headers, body: parsed });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
/** Answer every allowlisted upstream, counting the calls that got through. */
function stubUpstream(t) {
const asked = [];
const original = globalThis.fetch;
globalThis.fetch = async (url) => {
asked.push(String(url));
return new Response(STATION_BODY, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
t.after(() => {
globalThis.fetch = original;
});
return asked;
}
test('a maximal real session is never throttled', async (t) => {
const asked = stubUpstream(t);
const handler = mount();
// The bikeshare layer catalogues 15 systems and refreshes each one's status
// once every 60 s, so a minute of the busiest possible session is ~15
// requests. Run eight times that and expect every one of them served.
let served = 0;
for (let i = 0; i < 120; i += 1) {
const res = await request(handler);
if (res.status === 200) served += 1;
}
assert.equal(served, 120, 'the shipped ceiling leaves a real session alone');
assert.equal(asked.length, 120);
assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on');
});
test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 5 });
const responses = [];
for (let i = 0; i < 12; i += 1) responses.push(await request(handler));
const served = responses.filter((r) => r.status === 200);
const refused = responses.filter((r) => r.status === 429);
assert.equal(served.length, 5, 'exactly the admitted budget is served');
assert.equal(refused.length, 7);
assert.equal(refused[0].body.error, 'Rate limit exceeded');
assert.equal(refused[0].headers['Retry-After'], '10');
assert.equal(refused[0].headers['Cache-Control'], 'no-store');
assert.equal(
asked.length,
5,
'a refused request never reaches the GBFS operator',
);
});
test('a different client keeps its own budget', async (t) => {
stubUpstream(t);
const handler = mount({ ratePerMinute: 3 });
for (let i = 0; i < 6; i += 1)
await request(handler, { remoteAddress: '10.0.0.1' });
const noisy = await request(handler, { remoteAddress: '10.0.0.1' });
assert.equal(noisy.status, 429, 'the noisy client spent its own budget');
const quiet = await request(handler, { remoteAddress: '10.0.0.2' });
assert.equal(quiet.status, 200, 'a quiet client is not collateral');
});
test('the limiter does not displace the existing guards', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 10 });
const wrongMethod = await request(handler, { method: 'POST' });
assert.equal(wrongMethod.status, 405);
const offAllowlist = await request(handler, {
target: 'https://example.com/gbfs/station_status.json',
});
assert.equal(offAllowlist.status, 403);
assert.equal(offAllowlist.body.error, 'GBFS host not allowed');
const wrongPath = await request(handler, {
target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json',
});
assert.equal(wrongPath.status, 400);
assert.equal(asked.length, 0, 'none of those reached upstream');
});
+1
View File
@@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
'austin.publicbikesystem.net',
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
'gbfs.nextbike.net',
'gbfs.urbansharing.com',
'api.entur.io',
'api.cyclocity.fr',
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Barcelona system's host, and the stations farthest from its registered
// centre in the live feed (checked 2026-10-16). AMBici spans the metropolitan
// area, so it lists its south-western and north-eastern ends.
const SYSTEMS = {
'barcelona-bicing': {
host: 'barcelona.publicbikesystem.net',
provider: 'Bicing',
stations: [
{ name: 'AV. RASOS DE PEGUERA, 10', lat: 41.462095, lon: 2.178959 },
{ name: 'C/ 60, NÚMERO 25', lat: 41.3467746, lon: 2.1436235 },
],
},
'barcelona-ambici': {
host: 'gbfs.nextbike.net',
provider: 'AMBici',
stations: [
{ name: 'Poal', lat: 41.268865, lon: 1.943849 },
{ name: 'Platja del Manresà', lat: 41.460101, lon: 2.267271 },
{ name: 'Can Ruti', lat: 41.482205, lon: 2.237607 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bicing and AMBici are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.provider, expected.provider);
assert.deepEqual(system.hosts, [expected.host]);
for (const feed of [
system.stationInformationUrl,
system.stationStatusUrl,
]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(
isAllowedGbfsPath(url.pathname, url.hostname),
`${id} path ${url.pathname}`,
);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km, so the test above passes
// with any loadRadiusKm; get_bike_share uses the radius as registered.
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
system.centerLat,
system.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= system.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('a camera over central Barcelona activates both systems', () => {
// Plaça de Catalunya.
const active = computeInRangeCities({ lat: 41.387, lon: 2.17 });
assert.ok(active.has('barcelona-bicing'));
assert.ok(active.has('barcelona-ambici'));
});
test('the nextbike allowance is that one host, not its domain', () => {
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net'), true);
assert.equal(isAllowedGbfsHost('api.nextbike.net'), false);
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net.example'), false);
});
+4 -1
View File
@@ -97,7 +97,10 @@ export function createIngestion({
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
const stationMap = parts.model.stationsWithinCity(
city,
parts.model.parseStationInformation(payload),
);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each system's PBSC host, the station farthest from its registered centre,
// and the depots its live feed lists on other continents (checked 2026-10-16).
const CITIES = {
'bogota-tembici': {
host: 'bogota.publicbikesystem.net',
provider: 'Tembici Bogotá',
stations: [
{ name: '067 - KR 7B Bis con CL 152A', lat: 4.727729, lon: -74.026271 },
],
depots: [
{
station_id: '316',
name: 'Calle 13 - 316',
lat: -23.4008649,
lon: -46.3972181,
},
],
},
'santiago-bike-itau': {
host: 'santiago.publicbikesystem.net',
provider: 'Bike Itaú Santiago',
stations: [
{ name: 'LC077 - Mall Sport', lat: -33.3707813, lon: -70.5073122 },
],
depots: [
{
station_id: '459',
name: 'Galpon - 459',
lat: 40.4167747,
lon: -3.7037899,
},
{
station_id: '440',
name: 'Galpon - 440',
lat: -23.5690975,
lon: -46.697366,
},
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bogotá and Santiago are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, expected.provider);
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km; the station filter and
// get_bike_share use the radius as registered.
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= city.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('stations a feed lists on another continent are not loaded', async () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
const outermost = expected.stations[0];
const payload = {
data: {
stations: [
{
station_id: 'local',
name: outermost.name,
lat: outermost.lat,
lon: outermost.lon,
},
...expected.depots,
],
},
};
const state = {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
};
const ingestion = createIngestion({
state,
services: {},
parts,
source: {
async getStations(url) {
assert.equal(url, city.stationInformationUrl);
return payload;
},
},
});
const stations = await ingestion.loadCityStationInfo(id, 1);
assert.deepEqual(
[...stations.keys()],
['local'],
`${id} keeps only local stations`,
);
assert.equal(state._stationInfoCache.get(id), stations);
}
});
test('a feed with only far-away stations reports no station information', async () => {
const city = CITY_BY_ID.get('santiago-bike-itau');
const ingestion = createIngestion({
state: {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
},
services: {},
parts,
source: {
async getStations() {
return { data: { stations: CITIES['santiago-bike-itau'].depots } };
},
},
});
await assert.rejects(
ingestion.loadCityStationInfo('santiago-bike-itau', 1),
new RegExp(`No station information for ${city.city}`),
);
});
+25
View File
@@ -39,11 +39,36 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Drop stations outside a city's load radius. Some operators list depots on
* other continents as in-service stations in a city's feed (Bogotá's and
* Santiago's PBSC feeds carry warehouses in São Paulo and Madrid), which
* would otherwise draw as live docks thousands of kilometres away.
* @param {{ centerLat: number, centerLon: number, loadRadiusKm: number }} city
* @param {Map<string, Object>} stationMap - Parsed station info map.
* @returns {Map<string, Object>} Stations within the city's load radius.
*/
function stationsWithinCity(city, stationMap) {
const kept = new Map();
for (const [stationId, station] of stationMap) {
const distance = haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
if (distance <= city.loadRadiusKm) kept.set(stationId, station);
}
return kept;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
stationKey,
haversineKm,
stationsWithinCity,
extractStationsArray,
parseStationInformation,
parseStationStatus,
+50
View File
@@ -330,6 +330,30 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'bogota-tembici',
city: 'Bogotá, Colombia',
centerLat: 4.6533,
centerLon: -74.0836,
loadRadiusKm: 90,
stationInformationUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Tembici Bogotá',
},
{
id: 'santiago-bike-itau',
city: 'Santiago, Chile',
centerLat: -33.4372,
centerLon: -70.6506,
loadRadiusKm: 90,
stationInformationUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú Santiago',
},
{
id: 'toronto-bike-share',
city: 'Toronto, ON',
@@ -554,6 +578,32 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
provider: 'Bysykkel Lillestrøm',
},
{
id: 'barcelona-bicing',
city: 'Barcelona, Spain',
centerLat: 41.3926,
centerLon: 2.164,
loadRadiusKm: 40,
stationInformationUrl:
'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bicing',
},
// AMBici covers the metropolitan municipalities around Barcelona, from
// Castelldefels to Badalona, and overlaps Bicing only at the city edge.
{
id: 'barcelona-ambici',
city: 'Barcelona metropolitan area, Spain',
centerLat: 41.37,
centerLon: 2.07,
loadRadiusKm: 40,
stationInformationUrl:
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_information.json',
stationStatusUrl:
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
provider: 'AMBici',
},
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')