Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
856 changes: 428 additions & 428 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jose": "^6.0.10",
"yahoo-finance2": "^2.13.3",
"yahoo-finance2": "^2.14.2",
"zod": "^3.24.2"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/renderFlightPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { renderMarkup } from '../src/integrations/aerodatabox/renderer.js'
import { formatDelayString } from '../src/integrations/aerodatabox/formatters.js'
import { writeFileSync } from "node:fs"; // ignore typecheck error, typelinting is fine via CI
import { config } from '../src/config.js'
import type { FlightDisplayData } from "../src/types/trmnl/flightTypes.js";
import type { FlightDisplayData } from "../src/types/aerodatabox/types.js";
import type { MarkupVariant } from "../src/types/trmnl/types.js";

// Base in-flight sample; scenarios below override just the fields that matter per case.
Expand Down
13 changes: 4 additions & 9 deletions src/integrations/aerodatabox/aeroClient.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import type { AeroFlightContract, AeroFlightStatus } from '../../types/aerodatabox/types.js'
import { logger } from '../../utils/logger.js'
import type { Provider, CacheEntry } from '../../types/aerodatabox/types.js'

type CacheEntry = {
data: AeroFlightContract | null
at: number
status: AeroFlightStatus | null
}

type Provider = 'apimarket' | 'rapidapi'

const PROVIDERS: Record<Provider, { baseUrl: string; headers: (key: string) => Record<string, string> }> = {
apimarket: {
Expand Down Expand Up @@ -36,7 +30,8 @@ export class AeroClient {
private readonly NOT_FOUND_TTL_MS = 55 * 60 * 1000

// API queue system
private readonly MIN_INTERVAL_MS = 1100
// 60k unit / 2 req-s api.market tier as of July 2026 (up from 24k / 1 req-s) - 10% buffer under the 2 req/s ceiling
private readonly MIN_INTERVAL_MS = 550
private lastCallAt = 0
private processing = false
private readonly queue: Array<() => void> = []
Expand All @@ -61,7 +56,7 @@ export class AeroClient {
})
}

// flight API limits 1 req/s, at most 2 req/s
// flight API limits 2 req/s on the current tier
private async processQueue(): Promise<void> {
if (this.processing) return
this.processing = true
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/aerodatabox/renderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FlightDisplayData } from '../../types/trmnl/flightTypes.js'
import type { FlightDisplayData } from '../../types/aerodatabox/types.js'
import type { MarkupVariant } from '../../types/trmnl/types.js'
import { escapeHtml } from '../../utils/html.js'
import { buildArcSvg, formatDuration, planeSvg, AIRLINE_NAMES } from './formatters.js'
Expand Down
3 changes: 1 addition & 2 deletions src/integrations/aerodatabox/statusMapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// This module focuses on refining the flight metadata returned by the API
// Do some more data massaging to produce a FlightDisplayData object that's passed back to the renderer.
import type { AeroDepartureArrival, AeroFlightContract, AeroFlightStatus, AeroLocation } from '../../types/aerodatabox/types.js'
import type { FlightDisplayData } from '../../types/trmnl/flightTypes.js'
import type { AeroDepartureArrival, AeroFlightContract, AeroFlightStatus, AeroLocation, FlightDisplayData } from '../../types/aerodatabox/types.js'
import { calcProgress, formatDelayString, formatHeading } from './formatters.js'

// Past this point along the route, a low-altitude flight is descending toward
Expand Down
14 changes: 7 additions & 7 deletions src/integrations/wmata/wmataClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class WmataClient {
method: 'GET',
headers: { api_key: this.apiKey },
})
logger.debug(`[WMATA] Performing call for ${url}`)
logger.debug(`[MTRO] Performing call for ${url}`)
if (!res.ok) {
logger.warn(`Unable to retrieve WMATA response. Got ${res.status} - ${res.statusText}`)
throw new Error(`WMATA API Error: ${res.status} ${res.statusText}`)
Expand All @@ -47,7 +47,7 @@ export class WmataClient {
if (stationCodes.length === 0) return []
// WMATA expects comma-separated station codes in the path
const joined = stationCodes.map(encodeURIComponent).join(',')
logger.info(`[WMATA] Retrieving predictions for ${joined}`)
logger.info(`[MTRO] Retrieving predictions for ${joined}`)
const data = await this.getJson<RailPredictionResponse>(
`https://api.wmata.com/StationPrediction.svc/json/GetPrediction/${joined}`
)
Expand Down Expand Up @@ -95,7 +95,7 @@ export class WmataClient {

// Hydrate cache with any missing stations
private async fetchMissingStations(missing: string[]): Promise<(code: string) => RailPrediction[]> {
logger.debug(`[WMATA] Fetching missing stations: [${missing.join(',')}]`)
logger.debug(`[MTRO] Fetching missing stations: [${missing.join(',')}]`)
const trains = await this.getRailPredictions(missing)
const grouped = new Map<string, RailPrediction[]>()
for (const t of trains ?? []) {
Expand All @@ -115,21 +115,21 @@ export class WmataClient {
///////////////////////

async getIncidents(): Promise<MetroIncident[]> {
logger.info('[WMATA] Retrieving WMATA incidents')
logger.info('[MTRO] Retrieving WMATA incidents')
const data = await this.getJson<MetroIncidentResponse>('https://api.wmata.com/Incidents.svc/json/Incidents')
return data.Incidents
}

async getIncidentsCached(): Promise<MetroIncident[]> {
const now = Date.now()
if (this.cachedIncidents && now - this.cachedAtMs < this.INCIDENTS_TTL_MS) {
logger.debug('[WMATA] Cache hit for incidents')
logger.debug('[MTRO] Cache hit for incidents')
return this.cachedIncidents
}

if (this.inFlight) return this.inFlight

logger.debug('[WMATA] Cache miss for incidents — fetching')
logger.debug('[MTRO] Cache miss for incidents — fetching')
this.inFlight = (async () => {
const fresh = await this.getIncidents()
this.cachedIncidents = fresh
Expand All @@ -139,7 +139,7 @@ export class WmataClient {
})().catch((err) => {
this.inFlight = null
if (this.cachedIncidents) {
logger.warn('[WMATA] Fetch failed, returning stale cache:', String(err))
logger.warn('[MTRO] Fetch failed, returning stale cache:', String(err))
return this.cachedIncidents
}
throw err
Expand Down
4 changes: 2 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { registerTools } from './v1/mcp/registerTools.js'

import { logAuthedIdentity, logIncomingAuth } from './utils/authLogger.js'
import { logAuthedIdentity, logIncomingIP } from './utils/authLogger.js'
import { rateLimiter } from './utils/rateLimiter.js'

logger.info('Starting up subspace-api!')
Expand Down Expand Up @@ -74,7 +74,7 @@ server.use('/v1/trmnl', rateLimiter, trmnlRouter)

server.all(
'/mcp',
logIncomingAuth,
logIncomingIP,
authMiddleware,
userAuthMiddleware, // BFF pattern: verify X-User-Authorization for defense-in-depth
rateLimiter,
Expand Down
99 changes: 69 additions & 30 deletions src/types/aerodatabox/types.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
// AeroDataBox API response types
// Endpoint: GET /flights/{searchBy}/{searchParam}

export type AeroFlightStatus =
| 'Unknown'
| 'Expected'
| 'EnRoute'
| 'CheckIn'
| 'Boarding'
| 'GateClosed'
| 'Departed'
| 'Delayed'
| 'Approaching'
| 'Arrived'
| 'Canceled'
| 'Diverted'
| 'CanceledUncertain'

export type AeroTimeInfo = {
utc: string
local: string
}

export type AeroAirportInfo = {
name: string
icao?: string
Expand All @@ -47,16 +27,6 @@ export type AeroDepartureArrival = {
runway?: string
}

export type AeroLocation = {
lat: number
lon: number
pressureAltitude?: { feet?: number; meter?: number }
altitude?: { feet?: number; meter?: number }
groundSpeed?: { kt?: number; kmPerHour?: number; miPerHour?: number }
trueTrack?: { deg?: number }
reportedAtUtc?: string
}

export type AeroDistance = {
meter: number
km: number
Expand Down Expand Up @@ -87,3 +57,72 @@ export type AeroFlightContract = {
greatCircleDistance?: AeroDistance
location?: AeroLocation
}

export type AeroFlightStatus =
| 'Unknown'
| 'Expected'
| 'EnRoute'
| 'CheckIn'
| 'Boarding'
| 'GateClosed'
| 'Departed'
| 'Delayed'
| 'Approaching'
| 'Arrived'
| 'Canceled'
| 'Diverted'
| 'CanceledUncertain'

export type AeroLocation = {
lat: number
lon: number
pressureAltitude?: { feet?: number; meter?: number }
altitude?: { feet?: number; meter?: number }
groundSpeed?: { kt?: number; kmPerHour?: number; miPerHour?: number }
trueTrack?: { deg?: number }
reportedAtUtc?: string
}

export type AeroTimeInfo = {
utc: string
local: string
}

export type CacheEntry = {
data: AeroFlightContract | null
at: number
status: AeroFlightStatus | null
}

export type FlightDisplayData = {
flightIata: string
airlineIata: string
airlineIcao: string
depAirport: string
arrAirport: string
status: string
altitudeFt: string
speedMph: string
aircraftModel: string
aircraftIcao: string
heading: string
delayString: string | null // delayed/early/ontime
depTime: string // HH:MM actual/revised departure time in departure airport's local time
schedDep: string // HH:MM originally scheduled departure time (the "was" anchor); '--' if unknown
depDelayMin: number | null // departure delay vs schedule in minutes (+late, -early); null if unknown
eta: string // HH:MM actual/revised arrival time in arrival airport's local time
schedEta: string // HH:MM originally scheduled arrival time (the "was" anchor); '--' if unknown
delayMin: number | null // arrival delay vs schedule in minutes (+late, -early); null if unknown
minsToDeparture: number | null // minutes until departure (from "now"); null if unknown, <=0 if departed
minsRemaining: number | null // minutes until arrival (from "now"); null if unknown, <=0 if past
progressPct: number | null // 0-100, null if unknown
lastUpdated: string // relative staleness (e.g. "5m ago", "Yesterday")
}

export type Provider = 'apimarket' | 'rapidapi'

export type TrmnlFlightSettings = {
user_uuid: string
flight_numbers?: string | null
plugin_setting_id?: number | null
}
30 changes: 0 additions & 30 deletions src/types/trmnl/flightTypes.ts

This file was deleted.

6 changes: 4 additions & 2 deletions src/utils/authLogger.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Request, Response, NextFunction } from 'express'
import { logger } from './logger.js'

export function logIncomingAuth(req: Request, _res: Response, next: NextFunction) {
// Log incoming IP address and country for each request
// Mainly for debugging to prevent abuse or checking for TRMNL worker IPs. This is not an auth check, just logging
export function logIncomingIP(req: Request, _res: Response, next: NextFunction) {
const ip = (req.headers["cf-connecting-ip"] as string) ?? req.ip
const country = (req.headers["cf-ipcountry"] as string) ?? "unknown country"

const auth = req.headers.authorization ?? ""
const hasBearer = auth.toLowerCase().startsWith("bearer ")

logger.info(`[AUTH] Connection from ${ip} - ${country} bearer=${hasBearer}`)
logger.info(`Connection from ${ip} - ${country} - bearer=${hasBearer}`)
next()
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const rateLimiter: RateLimitRequestHandler = rateLimit({
return 60
}
logger.info(`Rate limit check for ${key ? 'authenticated' : 'anon'} - ${ip}`)
logger.info(`${req.method} ${req.originalUrl} auth=${Boolean((req as any).authInfo)} ip=${ip}`)
logger.info(`${req.method} ${req.originalUrl} auth=${Boolean((req as any).authInfo)}`)
return key ? 60 : 10
},
keyGenerator: (req: Request) => {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/trmnlMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function parseTrmnlMeta(raw: unknown): TrmnlMeta | null {
try {
return JSON.parse(raw) as TrmnlMeta
} catch {
logger.warn('[TRMNL] Failed to parse trmnl metadata JSON')
logger.warn('[TRML] Failed to parse trmnl metadata JSON')
}
}
return null
Expand Down
12 changes: 6 additions & 6 deletions src/v1/controllers/flights/flightInstallController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { storeTrmnlToken } from '../../../utils/dbConnector.js'
const sha256 = (v: string) => crypto.createHash('sha256').update(v).digest('hex')

const flightInstallController: RequestHandler = async (req, res): Promise<void> => {
logger.info('[TRMNL] Incoming flight install request!')
logger.debug('[TRMNL] Request debug:', { query: req.query, body: req.body })
logger.info('[TRML] Incoming flight install request!')
logger.debug('[TRML] Request debug:', { query: req.query, body: req.body })
const token = req.query.code as string | undefined
const callback = req.query.installation_callback_url as string

Expand All @@ -32,7 +32,7 @@ const flightInstallController: RequestHandler = async (req, res): Promise<void>
return
}

logger.debug('[TRMNL] Exchanging token for access token...')
logger.debug('[TRML] Exchanging token for access token...')
const trmnlResp = await fetch('https://trmnl.com/oauth/token', {
method: 'POST',
headers: {
Expand All @@ -49,7 +49,7 @@ const flightInstallController: RequestHandler = async (req, res): Promise<void>
const raw = await trmnlResp.text()

if (!trmnlResp.ok) {
logger.warn('[TRMNL] token exchange failed', raw)
logger.warn('[TRML] token exchange failed', raw)
res.status(502).json({ error: 'Bad Gateway', message: 'trmnl_exchange_failed' })
return
}
Expand All @@ -69,11 +69,11 @@ const flightInstallController: RequestHandler = async (req, res): Promise<void>
}

const hash = sha256(access_token)
logger.info('[TRMNL] Storing hashed access token...')
logger.info('[TRML] Storing hashed access token...')
logger.debug(hash)
await storeTrmnlToken(hash)

logger.debug('[TRMNL] Redirecting user back to', url.toString())
logger.debug('[TRML] Redirecting user back to', url.toString())
res.redirect(url.toString())
return
}
Expand Down
Loading
Loading