docs/adding-endpoints.mdx
All JSON API endpoints in World Monitor must use sebuf. Do not create standalone api/*.js or api/*.ts files for new data APIs — the legacy pattern is deprecated and being removed.
This guide walks through adding a new RPC to an existing service and adding an entirely new service.
Enforcement:
npm run lint:api-contractruns in CI (see.github/workflows/lint-code.yml). It walks every file underapi/, pairs each sebuf gateway (api/<domain>/v<N>/[rpc].ts) with a generated service undersrc/generated/server/worldmonitor/, and rejects any file that is neither a gateway nor listed inapi/api-route-exceptions.json. The manifest is the only escape hatch for endpoints that genuinely cannot be proto — OAuth callbacks, binary responses, upstream proxies, operator plumbing — and every entry is pinned to @SebastienMelki via.github/CODEOWNERS. Expect reviewer pushback on new entries.Generation freshness: After modifying any
.protofile, runmake generatebefore pushing. The generated TypeScript insrc/generated/is checked in and must stay in sync;.github/workflows/proto-check.ymlfails the PR if it drifts.
You need Go 1.21+ and Node.js 18+ installed. Everything else is installed automatically:
make install # one-time: installs buf, sebuf plugins, npm deps, proto deps
This installs:
Run code generation from the repo root:
make generate # regenerate all TypeScript + OpenAPI from protos
This produces three outputs per service:
src/generated/client/{domain}/v1/service_client.ts — typed fetch client for the frontendsrc/generated/server/{domain}/v1/service_server.ts — handler interface + route factory for the backenddocs/api/{Domain}Service.openapi.yaml + .json — OpenAPI v3 documentationExample: adding GetEarthquakeDetails to SeismologyService.
Create proto/worldmonitor/seismology/v1/get_earthquake_details.proto:
syntax = "proto3";
package worldmonitor.seismology.v1;
import "buf/validate/validate.proto";
import "worldmonitor/seismology/v1/earthquake.proto";
// GetEarthquakeDetailsRequest specifies which earthquake to retrieve.
message GetEarthquakeDetailsRequest {
// USGS event identifier (e.g., "us7000abcd").
string earthquake_id = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 100
];
}
// GetEarthquakeDetailsResponse contains the full earthquake record.
message GetEarthquakeDetailsResponse {
// The earthquake matching the requested ID.
Earthquake earthquake = 1;
}
Edit proto/worldmonitor/seismology/v1/service.proto:
import "worldmonitor/seismology/v1/get_earthquake_details.proto";
service SeismologyService {
// ... existing RPCs ...
// GetEarthquakeDetails retrieves a single earthquake by its USGS event ID.
rpc GetEarthquakeDetails(GetEarthquakeDetailsRequest) returns (GetEarthquakeDetailsResponse) {
option (sebuf.http.config) = {path: "/get-earthquake-details"};
}
}
make check # lint + generate in one step
At this point, npx tsc --noEmit will fail because the handler doesn't implement the new method yet. This is by design — the compiler enforces the contract.
Create server/worldmonitor/seismology/v1/get-earthquake-details.ts:
import type {
SeismologyServiceHandler,
ServerContext,
GetEarthquakeDetailsRequest,
GetEarthquakeDetailsResponse,
} from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server';
export const getEarthquakeDetails: SeismologyServiceHandler['getEarthquakeDetails'] = async (
_ctx: ServerContext,
req: GetEarthquakeDetailsRequest,
): Promise<GetEarthquakeDetailsResponse> => {
const response = await fetch(
`https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/${req.earthquakeId}.geojson`,
);
if (!response.ok) {
throw new Error(`USGS API error: ${response.status}`);
}
const f: any = await response.json();
return {
earthquake: {
id: f.id,
place: f.properties.place || '',
magnitude: f.properties.mag ?? 0,
depthKm: f.geometry.coordinates[2] ?? 0,
location: {
latitude: f.geometry.coordinates[1],
longitude: f.geometry.coordinates[0],
},
occurredAt: f.properties.time,
sourceUrl: f.properties.url || '',
},
};
};
Edit server/worldmonitor/seismology/v1/handler.ts:
import type { SeismologyServiceHandler } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server';
import { listEarthquakes } from './list-earthquakes';
import { getEarthquakeDetails } from './get-earthquake-details';
export const seismologyHandler: SeismologyServiceHandler = {
listEarthquakes,
getEarthquakeDetails,
};
npx tsc --noEmit # should pass with zero errors
The route is already live through the domain gateway in api/seismology/v1/[rpc].ts. createSeismologyServiceRoutes() picks up the new RPC automatically — no route-table or vite.config.ts edits are needed.
Open docs/api/SeismologyService.openapi.yaml — the new endpoint should appear with all validation constraints from your proto annotations.
Example: adding a hypothetical WeatherService. (No weather domain exists in this repo — the example below is purely illustrative; copy-pasting any path from this section will hit a 404.)
proto/worldmonitor/weather/v1/
Create proto/worldmonitor/weather/v1/weather_station.proto:
syntax = "proto3";
package worldmonitor.weather.v1;
import "buf/validate/validate.proto";
import "sebuf/http/annotations.proto";
// WeatherStation represents a single ground-based observation station.
message WeatherStation {
// Unique identifier (e.g., WMO station number).
string id = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 1
];
// Human-readable station name.
string name = 2;
// Operating network (e.g., "NWS", "WMO", "NOAA").
string network = 3;
// ISO 3166-1 alpha-2 country code where the station is located.
string country_code = 4;
// Date the station first reported observations, as Unix epoch milliseconds.
int64 first_seen_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}
Create proto/worldmonitor/weather/v1/list_weather_stations.proto:
syntax = "proto3";
package worldmonitor.weather.v1;
import "buf/validate/validate.proto";
import "worldmonitor/core/v1/pagination.proto";
import "worldmonitor/weather/v1/weather_station.proto";
// ListWeatherStationsRequest specifies filters for weather station data.
message ListWeatherStationsRequest {
// Filter by operating network (e.g., "NWS"). Empty returns all.
string network = 1;
// Filter by country code.
string country_code = 2 [(buf.validate.field).string.max_len = 2];
// Pagination parameters.
worldmonitor.core.v1.PaginationRequest pagination = 3;
}
// ListWeatherStationsResponse contains the matching stations.
message ListWeatherStationsResponse {
// The list of weather stations.
repeated WeatherStation stations = 1;
// Pagination metadata.
worldmonitor.core.v1.PaginationResponse pagination = 2;
}
Create proto/worldmonitor/weather/v1/service.proto:
syntax = "proto3";
package worldmonitor.weather.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/weather/v1/list_weather_stations.proto";
// WeatherService provides APIs for weather observation stations.
service WeatherService {
option (sebuf.http.service_config) = {base_path: "/api/weather/v1"};
// ListWeatherStations retrieves stations matching the given filters.
rpc ListWeatherStations(ListWeatherStationsRequest) returns (ListWeatherStationsResponse) {
option (sebuf.http.config) = {path: "/list-weather-stations"};
}
}
make check # lint + generate in one step
Create the handler directory and files:
server/worldmonitor/weather/v1/
├── handler.ts # thin re-export
└── list-weather-stations.ts # RPC implementation
server/worldmonitor/weather/v1/list-weather-stations.ts:
import type {
WeatherServiceHandler,
ServerContext,
ListWeatherStationsRequest,
ListWeatherStationsResponse,
} from '../../../../src/generated/server/worldmonitor/weather/v1/service_server';
export const listWeatherStations: WeatherServiceHandler['listWeatherStations'] = async (
_ctx: ServerContext,
req: ListWeatherStationsRequest,
): Promise<ListWeatherStationsResponse> => {
// Your implementation here — fetch from upstream API, transform to proto shape
return { stations: [], pagination: undefined };
};
server/worldmonitor/weather/v1/handler.ts:
import type { WeatherServiceHandler } from '../../../../src/generated/server/worldmonitor/weather/v1/service_server';
import { listWeatherStations } from './list-weather-stations';
export const weatherHandler: WeatherServiceHandler = {
listWeatherStations,
};
Create api/weather/v1/[rpc].ts as the thin Edge entry point for this service:
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createWeatherServiceRoutes } from '../../../src/generated/server/worldmonitor/weather/v1/service_server';
import { weatherHandler } from '../../../server/worldmonitor/weather/v1/handler';
export default createDomainGateway(
createWeatherServiceRoutes(weatherHandler, serverOptions),
);
There is no repository-wide catch-all gateway file or shared route array to edit. Each service owns its api/<domain>/v1/[rpc].ts gateway, and the generated create<Service>Routes(...) function enforces the RPC path names and HTTP annotations for that domain.
Edit vite.config.ts — add the lazy import and route mount inside the sebufApiPlugin() function. Follow the existing pattern (search for any other service to see the exact locations).
Create src/services/weather.ts:
import {
WeatherServiceClient,
type WeatherStation,
type ListWeatherStationsResponse,
} from '@/generated/client/worldmonitor/weather/v1/service_client';
import { createCircuitBreaker } from '@/utils';
export type { WeatherStation };
const client = new WeatherServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
const breaker = createCircuitBreaker<ListWeatherStationsResponse>({ name: 'Weather' });
const emptyFallback: ListWeatherStationsResponse = { stations: [] };
export async function fetchWeatherStations(network?: string): Promise<WeatherStation[]> {
const response = await breaker.execute(async () => {
return client.listWeatherStations({ network: network ?? '', countryCode: '', pagination: undefined });
}, emptyFallback);
return response.stations;
}
npx tsc --noEmit # zero errors
Every new public OpenAPI operation needs an explicit MCP decision before review. MCP is a curated agent surface, not a 1:1 mirror of REST: expose operations that are safe, predictable, and useful as tools; keep REST-only operations documented when they mutate state, spend per-call LLM or upstream budget, or need manual cache-key review.
Use this checklist for each new or changed RPC:
METHOD /api/... entry to that tool's _apiPaths._cacheKeys / _coverageKeys, freshness metadata, and seed-meta:<key> health coverage.tests/mcp-api-parity.test.mjs exclusion with the matching category prefix and a concrete reason.fetch-on-miss, include one enforced secondary signal (high-cardinality-input, paid-upstream, or llm-cost) and name the upstream cost, cardinality, and tier policy that makes open MCP exposure unsafe for now.mutating or llm-passthrough, document the separate threat/cost model before proposing an MCP wrapper../node_modules/.bin/tsx --test tests/mcp-api-parity.test.mjs and include the result in the PR.A covered operation is declared in a tool _apiPaths entry. For REST-only operations, the parity test accepts these exclusion categories:
| Category | Use when |
|---|---|
mutating | The handler writes state, queues work, refreshes caches, records webhooks, or has another persistent side effect. |
llm-passthrough | The operation invokes per-call LLM work and should not be opened as a generic MCP tool without a cost model. |
fetch-on-miss | The operation can call a paid, rate-limited, high-cardinality, or otherwise expensive upstream when the cache is cold. Include one enforced secondary signal in the reason: high-cardinality-input, paid-upstream, or llm-cost. |
admin | The operation is internal-only and protected by an explicit admin boundary, such as an admin key, internal-only middleware, or cron-only path. |
manual-mapping | The operation uses parameterized cache keys, inline Redis/Convex shapes, or another mapping the static parity walker cannot prove automatically. |
deferred-to-future-tool | The operation is pure-read and agent-useful, but belongs in a future MCP tool or expanded bundle rather than today's registry. |
The MCP reference docs render the current _apiPaths coverage table in MCP Overview. The parity test is canonical for the current covered/excluded split, so do not rely on stale counts in a PR description.
These conventions are enforced across the codebase. Follow them for consistency.
earthquake.proto, weather_station.protolist_earthquakes.proto, get_earthquake_details.protoservice.protosnake_case for file names and field namesAlways use int64 with Unix epoch milliseconds. Never use google.protobuf.Timestamp.
Always add the INT64_ENCODING_NUMBER annotation so TypeScript gets number instead of string:
int64 occurred_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
Import buf/validate/validate.proto and annotate fields at the proto level. These constraints flow through to the generated OpenAPI spec automatically.
Common patterns:
// Required string with length bounds
string id = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 100
];
// Numeric range (e.g., score 0-100)
double risk_score = 2 [
(buf.validate.field).double.gte = 0,
(buf.validate.field).double.lte = 100
];
// Non-negative value
double min_magnitude = 3 [(buf.validate.field).double.gte = 0];
// Coordinate bounds (prefer using core.v1.GeoCoordinates instead)
double latitude = 1 [
(buf.validate.field).double.gte = -90,
(buf.validate.field).double.lte = 90
];
Reuse these instead of redefining:
| Type | Import | Use for |
|---|---|---|
GeoCoordinates | worldmonitor/core/v1/geo.proto | Any lat/lon location (has built-in -90/90 and -180/180 bounds) |
BoundingBox | worldmonitor/core/v1/geo.proto | Spatial filtering |
TimeRange | worldmonitor/core/v1/time.proto | Time-based filtering (has INT64_ENCODING_NUMBER) |
PaginationRequest | worldmonitor/core/v1/pagination.proto | Request pagination (has page_size 1-100 constraint) |
PaginationResponse | worldmonitor/core/v1/pagination.proto | Response pagination metadata |
buf lint enforces comments on all messages, fields, services, RPCs, and enum values. Every proto element must have a // comment. This is not optional — buf lint will fail without them.
/api/{domain}/v1/{verb}-{noun} in kebab-case (e.g., /list-earthquakes, /get-vessel-snapshot)Always type the handler function against the generated interface using indexed access:
export const listWeatherStations: WeatherServiceHandler['listWeatherStations'] = async (
_ctx: ServerContext,
req: ListWeatherStationsRequest,
): Promise<ListWeatherStationsResponse> => {
// ...
};
This ensures the compiler catches any mismatch between your implementation and the proto contract.
Always pass { fetch: (...args) => globalThis.fetch(...args) } when creating clients:
const client = new WeatherServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
The empty string base URL works because both Vite dev server and Vercel serve the API on the same origin. The arrow-function wrapper around globalThis.fetch is required for Tauri compatibility AND for the runtime fetch interceptor — fetch.bind(globalThis) is banned because it freezes a reference to the global fetch at module-init time, which bypasses any later interceptor (auth headers, request logging, retry shims) installed on globalThis.fetch. The arrow-function wrapper resolves globalThis.fetch on every call.
Every time you run make generate, OpenAPI v3 specs are generated for each service:
docs/api/{Domain}Service.openapi.yaml — human-readable YAMLdocs/api/{Domain}Service.openapi.json — machine-readable JSONThese specs include:
buf.validate annotations (min/max, required fields, ranges)You do not need to write or maintain OpenAPI specs by hand. They are generated artifacts. If you need to change the API documentation, change the proto and regenerate.