From b2c8c56d888bb3dbaa129d775ed87b255659eb20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:12:57 +0000 Subject: [PATCH 1/5] feat: add SeaRates ocean tracking compatibility gateway Co-authored-by: Akshay Dodeja --- .env.local.example | 10 + api/info/sealines.ts | 3 + api/tracking.ts | 3 + package-lock.json | 17 + package.json | 2 +- packages/searates-compat/README.md | 101 ++++ packages/searates-compat/package.json | 25 + .../searates-compat/src/__fixtures__/t49.ts | 139 +++++ packages/searates-compat/src/client.test.ts | 99 ++++ packages/searates-compat/src/client.ts | 228 ++++++++ packages/searates-compat/src/http.ts | 137 +++++ packages/searates-compat/src/index.ts | 22 + packages/searates-compat/src/mapping.test.ts | 117 ++++ packages/searates-compat/src/mapping.ts | 538 ++++++++++++++++++ packages/searates-compat/src/service.test.ts | 113 ++++ packages/searates-compat/src/service.ts | 211 +++++++ packages/searates-compat/src/types.ts | 79 +++ packages/searates-compat/tsconfig.json | 20 + tsconfig.json | 14 +- vercel.json | 16 +- 20 files changed, 1890 insertions(+), 4 deletions(-) create mode 100644 api/info/sealines.ts create mode 100644 api/tracking.ts create mode 100644 packages/searates-compat/README.md create mode 100644 packages/searates-compat/package.json create mode 100644 packages/searates-compat/src/__fixtures__/t49.ts create mode 100644 packages/searates-compat/src/client.test.ts create mode 100644 packages/searates-compat/src/client.ts create mode 100644 packages/searates-compat/src/http.ts create mode 100644 packages/searates-compat/src/index.ts create mode 100644 packages/searates-compat/src/mapping.test.ts create mode 100644 packages/searates-compat/src/mapping.ts create mode 100644 packages/searates-compat/src/service.test.ts create mode 100644 packages/searates-compat/src/service.ts create mode 100644 packages/searates-compat/src/types.ts create mode 100644 packages/searates-compat/tsconfig.json diff --git a/.env.local.example b/.env.local.example index ad7cbf4e..0e867cfd 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,3 +19,13 @@ T49_API_BASE_URL=http://localhost:3000/v2 # POSTHOG_PROJECT_API_KEY=phc_... # POSTHOG_HOST=https://f.terminal49.com # POSTHOG_DEBUG=true + +# --- SeaRates ocean-tracking compatibility gateway --- +# Pass-through mode (default): leave these unset and send a Terminal49 API key +# in the SeaRates-compatible `api_key` query parameter. +# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`, and +# the gateway uses T49_SEARATES_API_TOKEN only for public T49 API requests. +# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY +# T49_SEARATES_POLL_TIMEOUT_MS=4000 +# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/api/info/sealines.ts b/api/info/sealines.ts new file mode 100644 index 00000000..08db7046 --- /dev/null +++ b/api/info/sealines.ts @@ -0,0 +1,3 @@ +import { createShippingLinesHandler } from '../../packages/searates-compat/src/http.js'; + +export default createShippingLinesHandler(); diff --git a/api/tracking.ts b/api/tracking.ts new file mode 100644 index 00000000..fba878cb --- /dev/null +++ b/api/tracking.ts @@ -0,0 +1,3 @@ +import { createTrackingHandler } from '../packages/searates-compat/src/http.js'; + +export default createTrackingHandler(); diff --git a/package-lock.json b/package-lock.json index cc27bbfd..6d1a89a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5268,6 +5268,10 @@ "resolved": "sdks/typescript-sdk", "link": true }, + "node_modules/@terminal49/searates-compat": { + "resolved": "packages/searates-compat", + "link": true + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -18400,6 +18404,19 @@ "fsevents": "~2.3.3" } }, + "packages/searates-compat": { + "name": "@terminal49/searates-compat", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.10.13", + "@vitest/coverage-v8": "4.1.10", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } + }, "sdks/typescript-sdk": { "name": "@terminal49/sdk", "version": "0.3.1", diff --git a/package.json b/package.json index b79c8f9c..a42ead26 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ], "type": "module", "scripts": { - "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/cli", + "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/cli", "check": "npm run lint:api && npm run lint --workspaces && npm run type-check --workspaces", "format": "vp fmt --write api && npm run format --workspaces", "lint:api": "vp lint api && vp fmt --check api", diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md new file mode 100644 index 00000000..0b5b0a0e --- /dev/null +++ b/packages/searates-compat/README.md @@ -0,0 +1,101 @@ +# SeaRates ocean-tracking compatibility gateway + +This package exposes a small SeaRates-compatible HTTP surface backed only by +Terminal49's public JSON:API. It is a compatibility gateway for ocean container, +Bill of Lading (BOL), and booking tracking. It is not a clone of SeaRates' +rates, schedules, air, parcel, road, route-history, or Automatic Identification +System (AIS) products. + +## Endpoints + +- `GET /tracking` +- `GET /info/sealines` + +The Vercel deployment maps these paths to `api/tracking.ts` and +`api/info/sealines.ts`. `/info/terminals` is intentionally omitted because the +Terminal49 public API can fetch a known terminal but does not provide a +supported-terminals list. + +## Configure authentication + +Choose one of two modes: + +### Pass-through mode + +Leave `T49_SEARATES_API_TOKEN` unset. The gateway treats the SeaRates `api_key` +query parameter as a Terminal49 API key and sends it upstream as +`Authorization: Bearer `. + +### Service-token mode + +Set both values: + +```bash +T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY +``` + +Clients send `YOUR_GATEWAY_KEY` as `api_key`. The gateway compares it in +constant time and uses `T49_SEARATES_API_TOKEN` only for requests to the public +Terminal49 API. This is one shared deployment credential, not a multi-tenant +billing or key-management system. + +Optional settings: + +```bash +T49_API_BASE_URL=https://api.terminal49.com/v2 +T49_SEARATES_POLL_TIMEOUT_MS=4000 +T49_SEARATES_POLL_INTERVAL_MS=500 +``` + +## Point an existing client at the gateway + +Change the SeaRates base URL and keep the existing query parameters: + +```bash +curl "https://YOUR_GATEWAY.example.com/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" +``` + +The gateway accepts `type=CT`, `type=BL`, and `type=BK`, plus `force_update`, +`route`, and `ais`. `force_update=true` requests a Terminal49 container refresh +when a tracked container already exists. The compatibility response always +includes SeaRates' route summary. Detailed route geometry and AIS pins are not +implemented. + +Fetch the carrier dictionary with: + +```bash +curl "https://YOUR_GATEWAY.example.com/info/sealines?api_key=YOUR_GATEWAY_KEY" +``` + +In service-token mode, `/info/sealines` also works without `api_key`, matching +SeaRates' public dictionary behavior. Its rows are generated from Terminal49 +`GET /shipping_lines`; they are not a hardcoded sample. + +## Asynchronous tracking behavior + +Terminal49 creates tracking requests asynchronously. On a cache miss, the +gateway: + +1. creates or reuses a Terminal49 tracking request; +2. polls it for a short, bounded interval; +3. returns the full SeaRates envelope if the shipment becomes available; or +4. returns `status: "success"`, `message: "PENDING"`, `metadata.status: + "UNKNOWN"`, and empty data arrays. + +Retry the same `GET /tracking` request after a pending response. The gateway +reuses the existing tracking request instead of creating another one. Terminal49 +failure reasons are translated to SeaRates-style messages such as +`WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and +`SEALINE_HASNT_PROVIDE_INFO`. + +## Compatibility limits + +- Timestamps are rendered in SeaRates' `YYYY-MM-DD HH:MM:SS` shape but remain + UTC because Terminal49 stores canonical event timestamps in UTC. +- SeaRates quota counters and cache expiration have no Terminal49 equivalent, + so those fields are `null`. +- Equipment ISO codes are reconstructed for common dry, reefer, open-top, + flat-rack, hard-top, and tank combinations. Unknown combinations are `null`. +- Holds, fees, Last Free Day (LFD), and other Terminal49-only terminal + intelligence are deliberately excluded. diff --git a/packages/searates-compat/package.json b/packages/searates-compat/package.json new file mode 100644 index 00000000..85764d2f --- /dev/null +++ b/packages/searates-compat/package.json @@ -0,0 +1,25 @@ +{ + "name": "@terminal49/searates-compat", + "version": "0.1.0", + "private": true, + "description": "SeaRates ocean tracking compatibility gateway backed by the Terminal49 public API", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vp test --run", + "lint": "vp lint src && vp fmt --check src", + "format": "vp fmt --write src", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "@vitest/coverage-v8": "4.1.10", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } +} diff --git a/packages/searates-compat/src/__fixtures__/t49.ts b/packages/searates-compat/src/__fixtures__/t49.ts new file mode 100644 index 00000000..860b8371 --- /dev/null +++ b/packages/searates-compat/src/__fixtures__/t49.ts @@ -0,0 +1,139 @@ +import type { JsonApiDocument } from '../types.js'; + +export const shipmentFixture: JsonApiDocument = { + data: { + id: 'shipment-1', + type: 'shipment', + attributes: { + bill_of_lading_number: 'MEDUFR030802', + shipping_line_scac: 'MSCU', + shipping_line_name: 'Mediterranean Shipping Company', + port_of_lading_locode: 'FRLEH', + port_of_discharge_locode: 'USNYC', + pol_atd_at: '2026-08-01T10:00:00Z', + pod_eta_at: '2026-08-20T14:00:00Z', + line_tracking_last_succeeded_at: '2026-08-10T12:30:00Z', + pod_vessel_name: 'EXAMPLE VESSEL', + pod_vessel_imo: '9811000', + }, + relationships: { + containers: { data: [{ id: 'container-1', type: 'container' }] }, + port_of_lading: { data: { id: 'port-pol', type: 'port' } }, + port_of_discharge: { data: { id: 'port-pod', type: 'port' } }, + }, + }, + included: [ + { + id: 'container-1', + type: 'container', + attributes: { + number: 'MSCU1234567', + equipment_type: 'dry', + equipment_length: 40, + equipment_height: 'high_cube', + current_status: 'on_ship', + }, + relationships: { + shipment: { data: { id: 'shipment-1', type: 'shipment' } }, + }, + }, + { + id: 'port-pol', + type: 'port', + attributes: { + name: 'Le Havre', + code: 'FRLEH', + country_code: 'FR', + time_zone: 'Europe/Paris', + latitude: 49.49, + longitude: 0.1, + }, + }, + { + id: 'port-pod', + type: 'port', + attributes: { + name: 'New York / New Jersey', + code: 'USNYC', + country_code: 'US', + time_zone: 'America/New_York', + latitude: 40.67, + longitude: -74.04, + }, + }, + ], +}; + +export const eventsFixture: JsonApiDocument = { + data: [ + { + id: 'event-1', + type: 'transport_event', + attributes: { + event: 'container.transport.full_in', + timestamp: '2026-07-31T08:00:00Z', + voyage_number: null, + data_source: 'shipping_line', + }, + relationships: { + location: { data: { id: 'port-pol', type: 'port' } }, + terminal: { data: null }, + vessel: { data: null }, + }, + }, + { + id: 'event-2', + type: 'transport_event', + attributes: { + event: 'container.transport.vessel_departed', + timestamp: '2026-08-01T10:00:00Z', + voyage_number: '421A', + data_source: 'shipping_line', + }, + relationships: { + location: { data: { id: 'port-pol', type: 'port' } }, + terminal: { data: null }, + vessel: { data: { id: 'vessel-1', type: 'vessel' } }, + }, + }, + ], + included: [ + { + id: 'port-pol', + type: 'port', + attributes: { + name: 'Le Havre', + code: 'FRLEH', + country_code: 'FR', + time_zone: 'Europe/Paris', + }, + }, + { + id: 'vessel-1', + type: 'vessel', + attributes: { + name: 'EXAMPLE VESSEL', + imo: '9811000', + mmsi: '353136000', + }, + }, + ], +}; + +export const shippingLinesFixture: JsonApiDocument = { + data: [ + { + id: 'line-1', + type: 'shipping_line', + attributes: { + name: 'Mediterranean Shipping Company', + short_name: 'MSC', + scac: 'MSCU', + alternative_scacs: ['MEDU'], + bill_of_lading_tracking_support: true, + booking_number_tracking_support: true, + container_number_tracking_support: true, + }, + }, + ], +}; diff --git a/packages/searates-compat/src/client.test.ts b/packages/searates-compat/src/client.test.ts new file mode 100644 index 00000000..b3d205fb --- /dev/null +++ b/packages/searates-compat/src/client.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { Terminal49PublicClient } from './client.js'; +import type { TrackingType } from './types.js'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/vnd.api+json' }, + }); +} + +describe('tracking request shaping', () => { + it.each([ + ['CT', 'container'], + ['BL', 'bill_of_lading'], + ['BK', 'booking_number'], + ] as const)('maps %s to T49 request_type %s', async (type, expected) => { + const requests: Array<{ init?: RequestInit; url: string }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + requests.push({ init, url }); + if (url.includes('/tracking_requests?')) { + return jsonResponse({ data: [] }); + } + return jsonResponse( + { + data: { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + relationships: { tracked_object: { data: null } }, + }, + }, + 201, + ); + }; + const client = new Terminal49PublicClient({ + apiToken: 'test-token', + baseUrl: 'https://api.example.test/v2', + fetchImpl, + pollTimeoutMs: 0, + }); + + await client.resolveTrackingRequest({ + number: 'EXAMPLE123', + scac: 'MSCU', + type: type as TrackingType, + }); + + const create = requests.find((request) => request.init?.method === 'POST'); + expect(JSON.parse(String(create?.init?.body))).toMatchObject({ + data: { + type: 'tracking_request', + attributes: { + request_number: 'EXAMPLE123', + request_type: expected, + scac: 'MSCU', + }, + }, + }); + expect(create?.init?.headers).toMatchObject({ + Authorization: 'Bearer test-token', + }); + }); + + it('uses asynchronous carrier detection when sealine is omitted', async () => { + const bodies: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).includes('/tracking_requests?')) { + return jsonResponse({ data: [] }); + } + bodies.push(String(init?.body)); + return jsonResponse( + { + data: { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + }, + }, + 201, + ); + }; + const client = new Terminal49PublicClient({ + apiToken: 'test-token', + fetchImpl, + pollTimeoutMs: 0, + }); + + await client.resolveTrackingRequest({ + number: 'MEDUFR030802', + type: 'BL', + }); + + expect(JSON.parse(bodies[0])).toMatchObject({ + data: { attributes: { auto_detect_vocc_scac: true } }, + }); + }); +}); diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts new file mode 100644 index 00000000..f8f0c8d3 --- /dev/null +++ b/packages/searates-compat/src/client.ts @@ -0,0 +1,228 @@ +import type { + JsonApiDocument, + JsonApiResource, + Terminal49TrackingType, + TrackingType, +} from './types.js'; + +export class Terminal49ApiError extends Error { + readonly status: number; + readonly document: JsonApiDocument | null; + + constructor(status: number, document: JsonApiDocument | null) { + const detail = document?.errors?.[0]?.detail; + super(detail || `Terminal49 API returned HTTP ${status}`); + this.name = 'Terminal49ApiError'; + this.status = status; + this.document = document; + } +} + +export interface Terminal49ClientConfig { + apiToken: string; + baseUrl?: string; + fetchImpl?: typeof fetch; + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +function normalizeToken(token: string): string { + return token.trim().replace(/^(Bearer|Token)\s+/i, ''); +} + +function resourceArray(document: JsonApiDocument): JsonApiResource[] { + return Array.isArray(document.data) ? document.data : []; +} + +function trackedObjectId(resource: JsonApiResource): string | null { + const tracked = resource.relationships?.tracked_object?.data; + return tracked && !Array.isArray(tracked) && tracked.type === 'shipment' + ? tracked.id + : null; +} + +function trackingType(type: TrackingType): Terminal49TrackingType { + switch (type) { + case 'CT': + return 'container'; + case 'BL': + return 'bill_of_lading'; + case 'BK': + return 'booking_number'; + default: { + const exhaustive: never = type; + return exhaustive; + } + } +} + +export class Terminal49PublicClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly pollIntervalMs: number; + private readonly pollTimeoutMs: number; + private readonly token: string; + + constructor(config: Terminal49ClientConfig) { + this.token = normalizeToken(config.apiToken); + this.baseUrl = (config.baseUrl || 'https://api.terminal49.com/v2').replace( + /\/+$/, + '', + ); + this.fetchImpl = config.fetchImpl || fetch; + this.pollIntervalMs = config.pollIntervalMs ?? 500; + this.pollTimeoutMs = config.pollTimeoutMs ?? 4_000; + } + + async shippingLines(): Promise { + return this.request('/shipping_lines'); + } + + async findShipment( + number: string, + type?: TrackingType, + ): Promise { + if (type === 'CT' || (!type && /^[A-Z]{4}\d{7}$/.test(number))) { + const containers = await this.request( + `/containers?filter[number]=${encodeURIComponent(number)}&include=shipment&page[size]=1`, + ); + const container = resourceArray(containers)[0]; + const shipmentReference = container?.relationships?.shipment?.data; + if ( + shipmentReference && + !Array.isArray(shipmentReference) && + shipmentReference.type === 'shipment' + ) { + return this.shipment(shipmentReference.id); + } + if (type === 'CT') return null; + } + + const shipments = await this.request( + `/shipments?number=${encodeURIComponent(number)}&include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal&page[size]=1`, + ); + const shipment = resourceArray(shipments)[0]; + return shipment ? this.shipment(shipment.id) : null; + } + + async shipment(id: string): Promise { + return this.request( + `/shipments/${encodeURIComponent(id)}?include=containers,port_of_lading,port_of_discharge,pod_terminal,destination,destination_terminal`, + ); + } + + async transportEvents(containerId: string): Promise { + return this.request( + `/containers/${encodeURIComponent(containerId)}/transport_events?include=location,terminal,vessel`, + ); + } + + async refreshContainer(containerId: string): Promise { + await this.request( + `/containers/${encodeURIComponent(containerId)}/refresh`, + { + method: 'PATCH', + }, + ); + } + + async resolveTrackingRequest(input: { + number: string; + scac?: string; + type: TrackingType; + }): Promise< + | { failedReason: string; state: 'failed' } + | { state: 'pending' } + | { shipmentId: string; state: 'created' } + > { + const existing = await this.trackingRequests(input.number, input.scac); + let requestResource = resourceArray(existing)[0]; + + if (!requestResource) { + const attributes: Record = { + request_number: input.number, + request_type: trackingType(input.type), + }; + if (input.scac) { + attributes.scac = input.scac; + } else { + attributes.auto_detect_vocc_scac = true; + } + const created = await this.request('/tracking_requests', { + body: JSON.stringify({ + data: { type: 'tracking_request', attributes }, + }), + headers: { 'Content-Type': 'application/vnd.api+json' }, + method: 'POST', + }); + requestResource = Array.isArray(created.data) + ? created.data[0] + : (created.data ?? undefined); + } + + if (!requestResource) return { state: 'pending' }; + + const deadline = Date.now() + this.pollTimeoutMs; + while (true) { + const status = String(requestResource.attributes?.status || 'pending'); + const shipmentId = trackedObjectId(requestResource); + if (shipmentId) return { shipmentId, state: 'created' }; + if (status === 'failed') { + return { + failedReason: String( + requestResource.attributes?.failed_reason || 'not_found', + ), + state: 'failed', + }; + } + if (Date.now() >= deadline) return { state: 'pending' }; + await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs)); + const next = await this.request( + `/tracking_requests/${encodeURIComponent(requestResource.id)}?include=tracked_object`, + ); + requestResource = Array.isArray(next.data) + ? next.data[0] + : (next.data ?? requestResource); + } + } + + private async trackingRequests( + number: string, + scac?: string, + ): Promise { + const params = new URLSearchParams({ + 'filter[request_number]': number, + include: 'tracked_object', + 'page[size]': '1', + }); + if (scac) params.set('filter[scac]', scac); + return this.request(`/tracking_requests?${params.toString()}`); + } + + private async request( + path: string, + init: RequestInit = {}, + ): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: 'application/vnd.api+json', + Authorization: `Bearer ${this.token}`, + ...init.headers, + }, + }); + let document: JsonApiDocument | null = null; + try { + document = (await response.json()) as JsonApiDocument; + } catch { + document = null; + } + if (!response.ok) { + throw new Terminal49ApiError(response.status, document); + } + if (!document) { + throw new Terminal49ApiError(response.status, null); + } + return document; + } +} diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts new file mode 100644 index 00000000..291aaa94 --- /dev/null +++ b/packages/searates-compat/src/http.ts @@ -0,0 +1,137 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; +import type { TrackingQuery, TrackingType } from './types.js'; + +type RequestLike = { + method?: string; + url?: string; +} & IncomingMessage; + +type ResponseLike = { + status(code: number): ResponseLike; + json(payload: unknown): void; + setHeader(name: string, value: string): void; +} & ServerResponse; + +function first(params: URLSearchParams, key: string): string | undefined { + const value = params.get(key)?.trim(); + return value || undefined; +} + +function booleanParam(params: URLSearchParams, key: string): boolean { + return ['1', 'true', 'yes'].includes( + (params.get(key) || '').trim().toLowerCase(), + ); +} + +function trackingType(value: string | undefined): TrackingType | undefined { + const normalized = value?.toUpperCase(); + return normalized === 'CT' || normalized === 'BL' || normalized === 'BK' + ? normalized + : undefined; +} + +function gatewayConfig(): GatewayConfig { + const pollTimeout = Number(process.env.T49_SEARATES_POLL_TIMEOUT_MS); + const pollInterval = Number(process.env.T49_SEARATES_POLL_INTERVAL_MS); + return { + apiBaseUrl: process.env.T49_API_BASE_URL, + clientSecret: process.env.T49_SEARATES_CLIENT_SECRET, + pollIntervalMs: + Number.isFinite(pollInterval) && pollInterval >= 0 + ? pollInterval + : undefined, + pollTimeoutMs: + Number.isFinite(pollTimeout) && pollTimeout >= 0 + ? pollTimeout + : undefined, + serviceApiToken: process.env.T49_SEARATES_API_TOKEN, + }; +} + +function setHeaders(response: ResponseLike): void { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + response.setHeader('Cache-Control', 'no-store'); +} + +function requestParams(request: RequestLike): URLSearchParams { + return new URL(request.url || '/', 'https://compat.invalid').searchParams; +} + +export function createTrackingHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return async ( + request: RequestLike, + response: ResponseLike, + ): Promise => { + setHeaders(response); + if (request.method === 'OPTIONS') { + response.status(204).json(null); + return; + } + if (request.method !== 'GET') { + response.status(405).json({ + status: 'error', + message: 'METHOD_NOT_ALLOWED', + data: null, + }); + return; + } + + const params = requestParams(request); + const number = (first(params, 'number') || '') + .replace(/\s+/g, '') + .toUpperCase(); + const sealine = first(params, 'sealine')?.toUpperCase(); + const rawType = first(params, 'type'); + if (rawType && !trackingType(rawType)) { + response.status(200).json({ + status: 'error', + message: 'WRONG_TYPE', + data: null, + }); + return; + } + const query: TrackingQuery = { + ais: booleanParam(params, 'ais'), + forceUpdate: booleanParam(params, 'force_update'), + number, + route: booleanParam(params, 'route'), + sealine, + type: trackingType(rawType), + }; + response + .status(200) + .json(await gateway.tracking(first(params, 'api_key'), query)); + }; +} + +export function createShippingLinesHandler( + gateway = new SeaRatesCompatibilityGateway(gatewayConfig()), +) { + return async ( + request: RequestLike, + response: ResponseLike, + ): Promise => { + setHeaders(response); + if (request.method === 'OPTIONS') { + response.status(204).json(null); + return; + } + if (request.method !== 'GET') { + response.status(405).json({ + status: 'error', + message: 'METHOD_NOT_ALLOWED', + data: null, + }); + return; + } + response + .status(200) + .json( + await gateway.shippingLines(first(requestParams(request), 'api_key')), + ); + }; +} diff --git a/packages/searates-compat/src/index.ts b/packages/searates-compat/src/index.ts new file mode 100644 index 00000000..63f5878a --- /dev/null +++ b/packages/searates-compat/src/index.ts @@ -0,0 +1,22 @@ +export { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +export { createShippingLinesHandler, createTrackingHandler } from './http.js'; +export { + mapEvent, + mapShippingLines, + mapTrackingPayload, + pendingEnvelope, +} from './mapping.js'; +export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; +export type { + JsonApiDocument, + JsonApiResource, + SeaRatesEnvelope, + SeaRatesEvent, + TrackingPayload, + TrackingQuery, + TrackingType, +} from './types.js'; diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts new file mode 100644 index 00000000..56bc180b --- /dev/null +++ b/packages/searates-compat/src/mapping.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + eventsFixture, + shipmentFixture, + shippingLinesFixture, +} from './__fixtures__/t49.js'; +import { mapEvent, mapShippingLines, mapTrackingPayload } from './mapping.js'; +import type { JsonApiResource, TrackingPayload } from './types.js'; + +describe('SeaRates response mapping', () => { + it('matches the documented tracking envelope and event fields', () => { + if (!shipmentFixture.data || Array.isArray(shipmentFixture.data)) { + throw new Error('Shipment fixture must contain one resource'); + } + const payload: TrackingPayload = { + eventsByContainerId: new Map([['container-1', eventsFixture]]), + shipment: shipmentFixture.data, + included: shipmentFixture.included || [], + requestedNumber: 'MEDUFR030802', + requestedType: 'BL', + }; + + const result = mapTrackingPayload(payload); + expect(result.status).toBe('success'); + expect(result.message).toBe('OK'); + expect(result.data).toMatchObject({ + metadata: { + type: 'BL', + number: 'MEDUFR030802', + sealine: 'MSCU', + status: 'IN_TRANSIT', + }, + locations: [ + { id: 1, locode: 'FRLEH' }, + { id: 2, locode: 'USNYC' }, + ], + route: { + pol: { location: 1, actual: true }, + pod: { location: 2, actual: false }, + }, + containers: [ + { + number: 'MSCU1234567', + iso_code: '45G1', + size_type: "40' High Cube Dry", + events: [ + { + event_code: 'GTIN', + status: 'CGI', + actual: true, + transport_type: 'TRUCK', + }, + { + event_code: 'DEPA', + status: 'VDL', + actual: true, + transport_type: 'VESSEL', + voyage: '421A', + }, + ], + }, + ], + }); + }); + + it.each([ + ['container.transport.empty_out', 'GTOT', 'CEP'], + ['container.transport.full_in', 'GTIN', 'CGI'], + ['container.transport.vessel_loaded', 'LOAD', 'CLL'], + ['container.transport.vessel_departed', 'DEPA', 'VDL'], + ['container.transport.transshipment_arrived', 'ARRI', 'VAT'], + ['container.transport.transshipment_discharged', 'DISC', 'CDT'], + ['container.transport.transshipment_loaded', 'LOAD', 'CLT'], + ['container.transport.transshipment_departed', 'DEPA', 'VDT'], + ['container.transport.vessel_arrived', 'ARRI', 'VAD'], + ['container.transport.full_out', 'GTOT', 'CGO'], + ['container.transport.delivered', 'DLVY', 'CDC'], + ['container.transport.empty_in', 'GTIN', 'CER'], + ['container.transport.rail_departed', 'DEPA', 'LTS'], + ])('maps %s to %s / %s', (event, code, status) => { + const resource: JsonApiResource = { + id: 'event', + type: 'transport_event', + attributes: { event, timestamp: '2026-08-01T10:00:00Z' }, + }; + expect( + mapEvent(resource, 1, { + facilities: new Map(), + locations: new Map(), + vessels: new Map(), + }), + ).toMatchObject({ event_code: code, status }); + }); + + it('maps every public T49 shipping line instead of using a sample list', () => { + expect(mapShippingLines(shippingLinesFixture)).toEqual({ + status: 'success', + message: 'OK', + data: [ + { + name: 'Mediterranean Shipping Company', + active: true, + active_types: { + ct: true, + bl: true, + bk: true, + bl_ct: false, + bk_ct: false, + }, + maintenance: false, + scac_codes: ['MSCU', 'MEDU'], + prefixes: ['MSC', 'MED'], + }, + ], + }); + }); +}); diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts new file mode 100644 index 00000000..7b7b2c64 --- /dev/null +++ b/packages/searates-compat/src/mapping.ts @@ -0,0 +1,538 @@ +import type { + JsonApiDocument, + JsonApiResource, + JsonObject, + SeaRatesEnvelope, + SeaRatesEvent, + TrackingPayload, + TrackingType, +} from './types.js'; + +interface EventMapping { + code: string; + description: string; + eventType: 'EQUIPMENT' | 'TRANSPORT'; + status: string; + transport: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; +} + +const EVENT_MAPPINGS: Record = { + 'container.transport.empty_out': { + code: 'GTOT', + description: 'Empty Picked-up at Depot', + eventType: 'EQUIPMENT', + status: 'CEP', + transport: 'TRUCK', + }, + 'container.transport.full_in': { + code: 'GTIN', + description: 'Gate in at Port terminal', + eventType: 'EQUIPMENT', + status: 'CGI', + transport: 'TRUCK', + }, + 'container.transport.vessel_loaded': { + code: 'LOAD', + description: 'Loaded on board', + eventType: 'EQUIPMENT', + status: 'CLL', + transport: 'VESSEL', + }, + 'container.transport.vessel_departed': { + code: 'DEPA', + description: 'Vessel Departure', + eventType: 'TRANSPORT', + status: 'VDL', + transport: 'VESSEL', + }, + 'container.transport.vessel_arrived': { + code: 'ARRI', + description: 'Vessel Arrival', + eventType: 'TRANSPORT', + status: 'VAD', + transport: 'VESSEL', + }, + 'container.transport.vessel_discharged': { + code: 'DISC', + description: 'Discharged from vessel', + eventType: 'EQUIPMENT', + status: 'CDD', + transport: 'VESSEL', + }, + 'container.transport.transshipment_arrived': { + code: 'ARRI', + description: 'Vessel Arrival at transshipment port', + eventType: 'TRANSPORT', + status: 'VAT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_discharged': { + code: 'DISC', + description: 'Discharged in transshipment', + eventType: 'EQUIPMENT', + status: 'CDT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_loaded': { + code: 'LOAD', + description: 'Loaded at transshipment port', + eventType: 'EQUIPMENT', + status: 'CLT', + transport: 'VESSEL', + }, + 'container.transport.transshipment_departed': { + code: 'DEPA', + description: 'Vessel Departure from transshipment port', + eventType: 'TRANSPORT', + status: 'VDT', + transport: 'VESSEL', + }, + 'container.transport.full_out': { + code: 'GTOT', + description: 'Gate out from final port', + eventType: 'EQUIPMENT', + status: 'CGO', + transport: 'TRUCK', + }, + 'container.transport.delivered': { + code: 'DLVY', + description: 'Container delivered to consignee', + eventType: 'EQUIPMENT', + status: 'CDC', + transport: 'TRUCK', + }, + 'container.transport.empty_in': { + code: 'GTIN', + description: 'Empty container returned to depot', + eventType: 'EQUIPMENT', + status: 'CER', + transport: 'TRUCK', + }, + 'container.transport.rail_loaded': { + code: 'LOAD', + description: 'Loaded on rail', + eventType: 'EQUIPMENT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_departed': { + code: 'DEPA', + description: 'Rail departure', + eventType: 'TRANSPORT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_arrived': { + code: 'ARRI', + description: 'Rail arrival', + eventType: 'TRANSPORT', + status: 'LTS', + transport: 'RAIL', + }, + 'container.transport.rail_unloaded': { + code: 'DISC', + description: 'Discharged from rail', + eventType: 'EQUIPMENT', + status: 'LTS', + transport: 'RAIL', + }, +}; + +function attrs(resource: JsonApiResource): JsonObject { + return resource.attributes || {}; +} + +function relatedId( + resource: JsonApiResource, + relationship: string, +): string | null { + const data = resource.relationships?.[relationship]?.data; + return data && !Array.isArray(data) ? data.id : null; +} + +function formatDate(value: unknown): string | null { + if (typeof value !== 'string' || !value) return null; + return value.replace('T', ' ').replace(/(?:\.\d+)?Z$/, ''); +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function resourceIndex( + resources: JsonApiResource[], +): Map { + return new Map(resources.map((resource) => [resource.id, resource])); +} + +function equipment(attributes: JsonObject): { + isoCode: string | null; + sizeType: string | null; +} { + const length = numberValue(attributes.equipment_length); + const height = stringValue(attributes.equipment_height); + const type = stringValue(attributes.equipment_type); + if (!length || !height || !type) return { isoCode: null, sizeType: null }; + + const first = length === 20 ? '2' : length === 40 ? '4' : 'L'; + const second = height === 'high_cube' ? '5' : '2'; + const typeCode: Record = { + dry: 'G1', + flat_rack: 'P1', + hard_top: 'U1', + open_top: 'U1', + reefer: 'R1', + tank: 'T1', + }; + const label: Record = { + dry: 'Dry', + flat_rack: 'Flat Rack', + hard_top: 'Hard Top', + open_top: 'Open Top', + reefer: 'Reefer', + tank: 'Tank', + }; + const heightLabel = height === 'high_cube' ? ' High Cube' : ''; + return { + isoCode: typeCode[type] ? `${first}${second}${typeCode[type]}` : null, + sizeType: `${length}'${heightLabel} ${label[type] || type}`, + }; +} + +function seaRatesStatus(value: unknown): string { + if (typeof value !== 'string') return 'UNKNOWN'; + if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { + return 'COMPLETED'; + } + if ( + [ + 'available', + 'awaiting_inland_transfer', + 'in_transit', + 'not_available', + 'on_ship', + ].includes(value) + ) { + return 'IN_TRANSIT'; + } + return 'UNKNOWN'; +} + +function defaultType(number: string, type?: TrackingType): TrackingType { + return type || (/^[A-Z]{4}\d{7}$/.test(number) ? 'CT' : 'BL'); +} + +function collectResources(payload: TrackingPayload): JsonApiResource[] { + const resources = [payload.shipment, ...payload.included]; + for (const document of payload.eventsByContainerId.values()) { + if (Array.isArray(document.data)) resources.push(...document.data); + resources.push(...(document.included || [])); + } + return resources; +} + +export function mapEvent( + resource: JsonApiResource, + orderId: number, + ids: { + facilities: Map; + locations: Map; + vessels: Map; + }, +): SeaRatesEvent | null { + const attributes = attrs(resource); + const eventName = stringValue(attributes.event); + if (!eventName) return null; + const normalizedName = eventName.replace('.estimated.', '.'); + const mapping = EVENT_MAPPINGS[normalizedName]; + if (!mapping) return null; + const estimated = eventName.includes('.estimated.'); + const locationId = relatedId(resource, 'location'); + const facilityId = relatedId(resource, 'terminal'); + const vesselId = relatedId(resource, 'vessel'); + return { + actual: !estimated, + date: formatDate(attributes.timestamp), + description: mapping.description, + event_type: mapping.eventType, + event_code: mapping.code, + status: mapping.status, + facility: facilityId ? (ids.facilities.get(facilityId) ?? null) : null, + is_additional_event: false, + is_date_from_sealine: attributes.data_source === 'shipping_line', + location: locationId ? (ids.locations.get(locationId) ?? null) : null, + order_id: orderId, + transport_type: mapping.transport, + type: mapping.transport === 'VESSEL' ? 'sea' : 'land', + vessel: vesselId ? (ids.vessels.get(vesselId) ?? null) : null, + voyage: stringValue(attributes.voyage_number), + }; +} + +export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { + const resources = collectResources(payload); + const byId = resourceIndex(resources); + const locationResources = resources.filter((resource) => + ['metro_area', 'port'].includes(resource.type), + ); + const facilityResources = resources.filter((resource) => + ['rail_terminal', 'terminal'].includes(resource.type), + ); + const vesselResources = resources.filter( + (resource) => resource.type === 'vessel', + ); + const locations = new Map( + locationResources.map((resource, index) => [resource.id, index + 1]), + ); + const facilities = new Map( + facilityResources.map((resource, index) => [resource.id, index + 1]), + ); + const vessels = new Map( + vesselResources.map((resource, index) => [resource.id, index + 1]), + ); + const shipmentAttributes = attrs(payload.shipment); + const containerResources = payload.included.filter( + (resource) => resource.type === 'container', + ); + const containerStatuses = containerResources.map((resource) => + seaRatesStatus(attrs(resource).current_status), + ); + const overallStatus = containerStatuses.includes('IN_TRANSIT') + ? 'IN_TRANSIT' + : containerStatuses.includes('COMPLETED') + ? 'COMPLETED' + : 'UNKNOWN'; + + const locationList = locationResources.map((resource) => { + const attributes = attrs(resource); + return { + id: locations.get(resource.id) || 0, + name: stringValue(attributes.name), + state: stringValue(attributes.state_abbr), + country: null, + country_code: stringValue(attributes.country_code), + locode: stringValue(attributes.code), + lat: numberValue(attributes.latitude), + lng: numberValue(attributes.longitude), + timezone: stringValue(attributes.time_zone), + }; + }); + + const facilityList = facilityResources.map((resource) => { + const attributes = attrs(resource); + const port = byId.get(relatedId(resource, 'port') || ''); + const portAttributes = port ? attrs(port) : {}; + return { + id: facilities.get(resource.id) || 0, + name: stringValue(attributes.name), + country_code: stringValue(portAttributes.country_code), + locode: stringValue(portAttributes.code), + bic_code: stringValue( + attributes.bic_facility_code || attributes.bic_code, + ), + smdg_code: stringValue(attributes.smdg_code), + lat: null, + lng: null, + }; + }); + + const vesselList = vesselResources.map((resource) => { + const attributes = attrs(resource); + return { + id: vessels.get(resource.id) || 0, + name: stringValue(attributes.name), + imo: stringValue(attributes.imo), + call_sign: null, + mmsi: stringValue(attributes.mmsi), + flag: null, + }; + }); + + if ( + vesselList.length === 0 && + stringValue(shipmentAttributes.pod_vessel_name) + ) { + vesselList.push({ + id: 1, + name: stringValue(shipmentAttributes.pod_vessel_name), + imo: stringValue(shipmentAttributes.pod_vessel_imo), + call_sign: null, + mmsi: null, + flag: null, + }); + } + + const containers = containerResources.map((resource) => { + const attributes = attrs(resource); + const eventDocument = payload.eventsByContainerId.get(resource.id); + const eventResources = Array.isArray(eventDocument?.data) + ? eventDocument.data + : []; + const events = eventResources + .map((event, index) => + mapEvent(event, index + 1, { facilities, locations, vessels }), + ) + .filter((event): event is SeaRatesEvent => event !== null) + .sort((left, right) => (left.date || '').localeCompare(right.date || '')) + .map((event, index) => ({ ...event, order_id: index + 1 })); + const equipmentDetails = equipment(attributes); + return { + number: stringValue(attributes.number), + iso_code: equipmentDetails.isoCode, + size_type: equipmentDetails.sizeType, + status: seaRatesStatus(attributes.current_status), + is_status_from_sealine: true, + events_mirrored: false, + events, + }; + }); + + const polId = relatedId(payload.shipment, 'port_of_lading'); + const podId = relatedId(payload.shipment, 'port_of_discharge'); + const destinationId = relatedId(payload.shipment, 'destination'); + + return { + status: 'success', + message: 'OK', + data: { + metadata: { + type: payload.requestedType, + number: + stringValue(shipmentAttributes.bill_of_lading_number) || + payload.requestedNumber, + sealine: stringValue(shipmentAttributes.shipping_line_scac), + sealine_name: stringValue(shipmentAttributes.shipping_line_name), + status: overallStatus, + is_status_from_sealine: true, + from_cache: true, + updated_at: formatDate( + shipmentAttributes.line_tracking_last_succeeded_at, + ), + cache_expires: null, + api_calls: null, + unique_shipments: null, + }, + locations: locationList, + facilities: facilityList, + route: { + prepol: { + location: polId ? (locations.get(polId) ?? null) : null, + date: formatDate( + shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, + ), + actual: Boolean(shipmentAttributes.pol_atd_at), + }, + pol: { + location: polId ? (locations.get(polId) ?? null) : null, + date: formatDate( + shipmentAttributes.pol_atd_at || shipmentAttributes.pol_etd_at, + ), + actual: Boolean(shipmentAttributes.pol_atd_at), + }, + pod: { + location: podId ? (locations.get(podId) ?? null) : null, + date: formatDate( + shipmentAttributes.pod_ata_at || shipmentAttributes.pod_eta_at, + ), + actual: Boolean(shipmentAttributes.pod_ata_at), + predictive_eta: null, + }, + postpod: { + location: destinationId + ? (locations.get(destinationId) ?? null) + : null, + date: formatDate( + shipmentAttributes.destination_ata_at || + shipmentAttributes.destination_eta_at, + ), + actual: shipmentAttributes.destination_ata_at + ? true + : shipmentAttributes.destination_eta_at + ? false + : null, + }, + }, + vessels: vesselList, + containers, + }, + }; +} + +export function pendingEnvelope( + number: string, + type?: TrackingType, + sealine?: string, +): SeaRatesEnvelope { + return { + status: 'success', + message: 'PENDING', + data: { + metadata: { + type: defaultType(number, type), + number, + sealine: sealine || null, + sealine_name: null, + status: 'UNKNOWN', + is_status_from_sealine: false, + from_cache: false, + updated_at: null, + cache_expires: null, + api_calls: null, + unique_shipments: null, + }, + locations: [], + facilities: [], + route: { + prepol: { location: null, date: null, actual: null }, + pol: { location: null, date: null, actual: null }, + pod: { + location: null, + date: null, + actual: null, + predictive_eta: null, + }, + postpod: { location: null, date: null, actual: null }, + }, + vessels: [], + containers: [], + }, + }; +} + +export function mapShippingLines(document: JsonApiDocument): SeaRatesEnvelope { + const resources = Array.isArray(document.data) ? document.data : []; + return { + status: 'success', + message: 'OK', + data: resources.map((resource) => { + const attributes = attrs(resource); + const primary = stringValue(attributes.scac); + const alternatives = Array.isArray(attributes.alternative_scacs) + ? attributes.alternative_scacs.filter( + (value): value is string => typeof value === 'string', + ) + : []; + const scacCodes = primary + ? [primary, ...alternatives.filter((value) => value !== primary)] + : alternatives; + return { + name: stringValue(attributes.name), + active: true, + active_types: { + ct: attributes.container_number_tracking_support === true, + bl: attributes.bill_of_lading_tracking_support === true, + bk: attributes.booking_number_tracking_support === true, + bl_ct: false, + bk_ct: false, + }, + maintenance: false, + scac_codes: scacCodes, + prefixes: scacCodes.map((scac) => scac.slice(0, 3)), + }; + }), + }; +} diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts new file mode 100644 index 00000000..5a4abbf6 --- /dev/null +++ b/packages/searates-compat/src/service.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + eventsFixture, + shipmentFixture, + shippingLinesFixture, +} from './__fixtures__/t49.js'; +import { SeaRatesCompatibilityGateway } from './service.js'; +import type { TrackingQuery } from './types.js'; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/vnd.api+json' }, + }); +} + +const query: TrackingQuery = { + ais: false, + forceUpdate: false, + number: 'MEDUFR030802', + route: false, + sealine: 'MSCU', + type: 'BL', +}; + +describe('SeaRates compatibility gateway', () => { + it('returns the documented contract from fixture-backed public API calls', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = String(input); + if (url.includes('/shipments?')) { + return response({ + data: [shipmentFixture.data], + included: shipmentFixture.included, + }); + } + if (url.includes('/shipments/shipment-1')) { + return response(shipmentFixture); + } + if (url.includes('/transport_events')) return response(eventsFixture); + throw new Error(`Unexpected fixture request: ${url}`); + }; + const gateway = new SeaRatesCompatibilityGateway({ + apiBaseUrl: 'https://api.example.test/v2', + fetchImpl, + }); + + const result = await gateway.tracking('pass-through-key', query); + + expect(result).toMatchObject({ + status: 'success', + message: 'OK', + data: { + metadata: { number: 'MEDUFR030802', type: 'BL' }, + locations: expect.any(Array), + facilities: expect.any(Array), + route: expect.any(Object), + vessels: expect.any(Array), + containers: [{ events: expect.any(Array) }], + }, + }); + }); + + it('returns SeaRates errors for missing and bad api_key values', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + clientSecret: 'gateway-key', + serviceApiToken: 'service-token', + }); + await expect(gateway.tracking(undefined, query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_REQUIRED', + data: null, + }); + await expect(gateway.tracking('wrong-key', query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_WRONG', + data: null, + }); + }); + + it('maps an upstream authentication rejection to API_KEY_WRONG', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + fetchImpl: async () => + response( + { + errors: [{ status: '401', title: 'Unauthorized' }], + data: null, + }, + 401, + ), + }); + await expect(gateway.tracking('bad-t49-key', query)).resolves.toEqual({ + status: 'error', + message: 'API_KEY_WRONG', + data: null, + }); + }); + + it('serves the sealines dictionary from /shipping_lines', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + serviceApiToken: 'service-token', + fetchImpl: async (input) => { + expect(String(input)).toEndWith('/shipping_lines'); + return response(shippingLinesFixture); + }, + }); + const result = await gateway.shippingLines(); + expect(result).toMatchObject({ + status: 'success', + message: 'OK', + data: [{ scac_codes: ['MSCU', 'MEDU'] }], + }); + }); +}); diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts new file mode 100644 index 00000000..d29a24da --- /dev/null +++ b/packages/searates-compat/src/service.ts @@ -0,0 +1,211 @@ +import { timingSafeEqual } from 'node:crypto'; +import { + Terminal49ApiError, + Terminal49PublicClient, + type Terminal49ClientConfig, +} from './client.js'; +import { + mapShippingLines, + mapTrackingPayload, + pendingEnvelope, +} from './mapping.js'; +import type { + JsonApiDocument, + JsonApiResource, + SeaRatesEnvelope, + TrackingPayload, + TrackingQuery, + TrackingType, +} from './types.js'; + +export interface GatewayConfig { + apiBaseUrl?: string; + clientSecret?: string; + fetchImpl?: typeof fetch; + pollIntervalMs?: number; + pollTimeoutMs?: number; + serviceApiToken?: string; +} + +function errorEnvelope(message: string): SeaRatesEnvelope { + return { status: 'error', message, data: null }; +} + +function secureEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} + +function inferType(number: string, explicit?: TrackingType): TrackingType { + return explicit || (/^[A-Z]{4}\d{7}$/.test(number) ? 'CT' : 'BL'); +} + +function shipmentFrom(document: JsonApiDocument): JsonApiResource | null { + if (!document.data || Array.isArray(document.data)) return null; + return document.data.type === 'shipment' ? document.data : null; +} + +function containerResources(document: JsonApiDocument): JsonApiResource[] { + return (document.included || []).filter( + (resource) => resource.type === 'container', + ); +} + +function upstreamErrorMessage(error: Terminal49ApiError): string { + if (error.status === 401) return 'API_KEY_WRONG'; + if (error.status === 403) return 'API_KEY_ACCESS_DENIED'; + if (error.status === 429) return 'API_KEY_RATE_LIMIT'; + if (error.status === 422) { + const detail = error.document?.errors?.[0]?.detail || ''; + if (/scac|shipping line/i.test(detail)) return 'WRONG_SEALINE'; + return 'WRONG_NUMBER'; + } + return error.status >= 500 ? 'SEALINE_NO_RESPONSE' : 'WRONG_PARAMETERS'; +} + +export class SeaRatesCompatibilityGateway { + private readonly config: GatewayConfig; + + constructor(config: GatewayConfig = {}) { + this.config = config; + } + + async tracking( + apiKey: string | undefined, + query: TrackingQuery, + ): Promise { + if (!apiKey) return errorEnvelope('API_KEY_REQUIRED'); + if (!query.number) return errorEnvelope('WRONG_NUMBER'); + + let client: Terminal49PublicClient; + try { + client = this.client(apiKey, false); + } catch { + return errorEnvelope('API_KEY_WRONG'); + } + + const type = inferType(query.number, query.type); + try { + let shipmentDocument = await client.findShipment(query.number, type); + if (!shipmentDocument) { + const resolution = await client.resolveTrackingRequest({ + number: query.number, + scac: + query.sealine && query.sealine !== 'AUTO' + ? query.sealine + : undefined, + type, + }); + if (resolution.state === 'failed') { + const message = + resolution.failedReason === 'scac_auto_detect_failed' + ? 'AUTO_CANT_DETECT_SEALINE' + : resolution.failedReason === 'invalid_number' + ? 'WRONG_NUMBER' + : 'SEALINE_HASNT_PROVIDE_INFO'; + return errorEnvelope(message); + } + if (resolution.state === 'pending') { + return pendingEnvelope(query.number, type, query.sealine); + } + shipmentDocument = await client.shipment(resolution.shipmentId); + } + + const shipment = shipmentFrom(shipmentDocument); + if (!shipment) return pendingEnvelope(query.number, type, query.sealine); + let containers = containerResources(shipmentDocument); + + if (query.forceUpdate && containers.length > 0) { + await Promise.all( + containers.map((container) => client.refreshContainer(container.id)), + ); + shipmentDocument = await client.shipment(shipment.id); + containers = containerResources(shipmentDocument); + } + + const eventsByContainerId = new Map(); + await Promise.all( + containers.map(async (container) => { + eventsByContainerId.set( + container.id, + await client.transportEvents(container.id), + ); + }), + ); + const payload: TrackingPayload = { + eventsByContainerId, + included: shipmentDocument.included || [], + requestedNumber: query.number, + requestedType: type, + shipment, + }; + return mapTrackingPayload(payload); + } catch (error) { + return errorEnvelope( + error instanceof Terminal49ApiError + ? upstreamErrorMessage(error) + : 'SEALINE_NO_RESPONSE', + ); + } + } + + async shippingLines(apiKey?: string): Promise { + let client: Terminal49PublicClient; + try { + client = this.client(apiKey, true); + } catch { + return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'API_KEY_REQUIRED'); + } + try { + return mapShippingLines(await client.shippingLines()); + } catch (error) { + return errorEnvelope( + error instanceof Terminal49ApiError + ? upstreamErrorMessage(error) + : 'SEALINE_NO_RESPONSE', + ); + } + } + + private client( + apiKey: string | undefined, + allowServiceTokenWithoutKey: boolean, + ): Terminal49PublicClient { + const serviceToken = this.config.serviceApiToken?.trim(); + let token: string; + if (serviceToken) { + if ( + !allowServiceTokenWithoutKey && + (!apiKey || + !this.config.clientSecret || + !secureEqual(apiKey, this.config.clientSecret)) + ) { + throw new Error('Invalid compatibility gateway key'); + } + if ( + apiKey && + this.config.clientSecret && + !secureEqual(apiKey, this.config.clientSecret) + ) { + throw new Error('Invalid compatibility gateway key'); + } + token = serviceToken; + } else { + if (!apiKey) throw new Error('API key is required'); + token = apiKey; + } + + const clientConfig: Terminal49ClientConfig = { + apiToken: token, + baseUrl: this.config.apiBaseUrl, + fetchImpl: this.config.fetchImpl, + pollIntervalMs: this.config.pollIntervalMs, + pollTimeoutMs: this.config.pollTimeoutMs, + }; + return new Terminal49PublicClient(clientConfig); + } +} diff --git a/packages/searates-compat/src/types.ts b/packages/searates-compat/src/types.ts new file mode 100644 index 00000000..99ceecb5 --- /dev/null +++ b/packages/searates-compat/src/types.ts @@ -0,0 +1,79 @@ +export type JsonObject = { [key: string]: JsonValue }; +export type JsonValue = + | JsonObject + | JsonValue[] + | boolean + | null + | number + | string; + +export interface ResourceIdentifier { + id: string; + type: string; +} + +export interface JsonApiResource extends ResourceIdentifier { + attributes?: JsonObject; + relationships?: Record< + string, + { data?: ResourceIdentifier | ResourceIdentifier[] | null } + >; +} + +export interface JsonApiDocument { + data: JsonApiResource | JsonApiResource[] | null; + included?: JsonApiResource[]; + errors?: Array<{ + code?: string; + detail?: string; + status?: string; + title?: string; + }>; +} + +export type TrackingType = 'BL' | 'BK' | 'CT'; +export type Terminal49TrackingType = + | 'bill_of_lading' + | 'booking_number' + | 'container'; + +export interface TrackingQuery { + ais: boolean; + forceUpdate: boolean; + number: string; + route: boolean; + sealine?: string; + type?: TrackingType; +} + +export interface SeaRatesEvent { + actual: boolean; + date: string | null; + description: string; + event_code: string; + event_type: 'EQUIPMENT' | 'TRANSPORT'; + facility: number | null; + is_additional_event: boolean; + is_date_from_sealine: boolean; + location: number | null; + order_id: number; + status: string; + transport_type: 'BARGE' | 'RAIL' | 'TRUCK' | 'VESSEL'; + type: 'land' | 'sea'; + vessel: number | null; + voyage: string | null; +} + +export interface SeaRatesEnvelope { + status: 'error' | 'success'; + message: string; + data: JsonValue; +} + +export interface TrackingPayload { + eventsByContainerId: Map; + shipment: JsonApiResource; + included: JsonApiResource[]; + requestedNumber: string; + requestedType: TrackingType; +} diff --git a/packages/searates-compat/tsconfig.json b/packages/searates-compat/tsconfig.json new file mode 100644 index 00000000..188b2bfa --- /dev/null +++ b/packages/searates-compat/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "lib": ["ES2022"], + "moduleResolution": "NodeNext", + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index ddfc83f2..749aa930 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,16 @@ "isolatedModules": true, "noEmit": true }, - "include": ["api/**/*", "packages/mcp/src/**/*"], - "exclude": ["node_modules", "packages/mcp/node_modules", "packages/mcp/dist"] + "include": [ + "api/**/*", + "packages/mcp/src/**/*", + "packages/searates-compat/src/**/*" + ], + "exclude": [ + "node_modules", + "packages/mcp/node_modules", + "packages/mcp/dist", + "packages/searates-compat/node_modules", + "packages/searates-compat/dist" + ] } diff --git a/vercel.json b/vercel.json index 8000aea7..f8752374 100644 --- a/vercel.json +++ b/vercel.json @@ -2,11 +2,17 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, "installCommand": "npm ci", - "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp", + "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat", "functions": { "api/mcp.ts": { "maxDuration": 30 }, + "api/tracking.ts": { + "maxDuration": 30 + }, + "api/info/sealines.ts": { + "maxDuration": 15 + }, "api/oauth-protected-resource.ts": { "maxDuration": 10 }, @@ -15,6 +21,14 @@ } }, "rewrites": [ + { + "source": "/tracking", + "destination": "/api/tracking" + }, + { + "source": "/info/sealines", + "destination": "/api/info/sealines" + }, { "source": "/.well-known/oauth-protected-resource", "destination": "/api/oauth-protected-resource" From 004605f9053f6276766a4498307496eb40033143 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:13:45 +0000 Subject: [PATCH 2/5] fix: stabilize compatibility mappings and tests Co-authored-by: Akshay Dodeja --- packages/searates-compat/src/client.ts | 3 ++- packages/searates-compat/src/mapping.ts | 10 +++++++++- packages/searates-compat/src/service.test.ts | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/searates-compat/src/client.ts b/packages/searates-compat/src/client.ts index f8f0c8d3..6db454a5 100644 --- a/packages/searates-compat/src/client.ts +++ b/packages/searates-compat/src/client.ts @@ -136,7 +136,8 @@ export class Terminal49PublicClient { | { shipmentId: string; state: 'created' } > { const existing = await this.trackingRequests(input.number, input.scac); - let requestResource = resourceArray(existing)[0]; + let requestResource: JsonApiResource | undefined = + resourceArray(existing)[0]; if (!requestResource) { const attributes: Record = { diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index 7b7b2c64..3fb7093c 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -232,7 +232,15 @@ function collectResources(payload: TrackingPayload): JsonApiResource[] { if (Array.isArray(document.data)) resources.push(...document.data); resources.push(...(document.included || [])); } - return resources; + const unique = new Map(); + for (const resource of resources) { + const key = `${resource.type}:${resource.id}`; + const existing = unique.get(key); + if (!existing || Object.keys(attrs(resource)).length > 0) { + unique.set(key, resource); + } + } + return [...unique.values()]; } export function mapEvent( diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 5a4abbf6..8cd22cfc 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -99,7 +99,7 @@ describe('SeaRates compatibility gateway', () => { const gateway = new SeaRatesCompatibilityGateway({ serviceApiToken: 'service-token', fetchImpl: async (input) => { - expect(String(input)).toEndWith('/shipping_lines'); + expect(String(input).endsWith('/shipping_lines')).toBe(true); return response(shippingLinesFixture); }, }); From 90127977a10a897cc2d13d08e2d9549298ec61fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:14:35 +0000 Subject: [PATCH 3/5] test: cover SeaRates HTTP query contract Co-authored-by: Akshay Dodeja --- packages/searates-compat/package.json | 2 +- packages/searates-compat/src/http.test.ts | 100 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/searates-compat/src/http.test.ts diff --git a/packages/searates-compat/package.json b/packages/searates-compat/package.json index 85764d2f..9dd0a1a3 100644 --- a/packages/searates-compat/package.json +++ b/packages/searates-compat/package.json @@ -8,7 +8,7 @@ "types": "./dist/index.d.ts", "scripts": { "build": "tsc", - "test": "vp test --run", + "test": "vp test --run src", "lint": "vp lint src && vp fmt --check src", "format": "vp fmt --write src", "type-check": "tsc --noEmit" diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts new file mode 100644 index 00000000..2744533a --- /dev/null +++ b/packages/searates-compat/src/http.test.ts @@ -0,0 +1,100 @@ +import type { IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vite-plus/test'; +import { createTrackingHandler } from './http.js'; +import { SeaRatesCompatibilityGateway } from './service.js'; +import type { SeaRatesEnvelope, TrackingQuery, TrackingType } from './types.js'; + +class CapturingGateway extends SeaRatesCompatibilityGateway { + apiKey?: string; + query?: TrackingQuery; + + override async tracking( + apiKey: string | undefined, + query: TrackingQuery, + ): Promise { + this.apiKey = apiKey; + this.query = query; + return { status: 'success', message: 'OK', data: { containers: [] } }; + } +} + +function request(url: string): IncomingMessage { + // SAFETY: The handler reads only method and url from this test double. + return { method: 'GET', url } as IncomingMessage; +} + +function response(): { + body?: unknown; + response: Parameters>[1]; + status?: number; +} { + const state: { body?: unknown; status?: number } = {}; + const responseDouble = { + setHeader: () => undefined, + status(code: number) { + state.status = code; + return responseDouble; + }, + json(payload: unknown) { + state.body = payload; + }, + }; + // SAFETY: The handler uses only setHeader, status, and json on this test double. + return { + get body() { + return state.body; + }, + get status() { + return state.status; + }, + response: responseDouble as unknown as Parameters< + ReturnType + >[1], + }; +} + +describe('GET /tracking contract', () => { + it('parses the SeaRates query and returns its JSON envelope', async () => { + const gateway = new CapturingGateway(); + const handler = createTrackingHandler(gateway); + const output = response(); + + await handler( + request( + '/tracking?api_key=gateway-key&number=mscu1234567&type=CT&sealine=mscu&force_update=true&route=1&ais=yes', + ), + output.response, + ); + + expect(output.status).toBe(200); + expect(output.body).toEqual({ + status: 'success', + message: 'OK', + data: { containers: [] }, + }); + expect(gateway.apiKey).toBe('gateway-key'); + expect(gateway.query).toEqual({ + ais: true, + forceUpdate: true, + number: 'MSCU1234567', + route: true, + sealine: 'MSCU', + type: 'CT' satisfies TrackingType, + }); + }); + + it('returns a SeaRates-style WRONG_TYPE envelope', async () => { + const handler = createTrackingHandler(new CapturingGateway()); + const output = response(); + await handler( + request('/tracking?api_key=gateway-key&number=EXAMPLE&type=AIR'), + output.response, + ); + expect(output.status).toBe(200); + expect(output.body).toEqual({ + status: 'error', + message: 'WRONG_TYPE', + data: null, + }); + }); +}); From f3153589999156b636313c8958fa8ad3d3a245c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 19:17:08 +0000 Subject: [PATCH 4/5] fix: align empty and auth responses with SeaRates Co-authored-by: Akshay Dodeja --- packages/searates-compat/README.md | 14 ++++--- packages/searates-compat/src/http.test.ts | 2 +- packages/searates-compat/src/http.ts | 6 +-- packages/searates-compat/src/index.ts | 2 +- packages/searates-compat/src/mapping.test.ts | 3 +- packages/searates-compat/src/mapping.ts | 15 +++---- packages/searates-compat/src/service.test.ts | 42 ++++++++++++++++++-- packages/searates-compat/src/service.ts | 28 +++++++------ 8 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md index 0b5b0a0e..7c96c507 100644 --- a/packages/searates-compat/README.md +++ b/packages/searates-compat/README.md @@ -80,12 +80,14 @@ gateway: 1. creates or reuses a Terminal49 tracking request; 2. polls it for a short, bounded interval; 3. returns the full SeaRates envelope if the shipment becomes available; or -4. returns `status: "success"`, `message: "PENDING"`, `metadata.status: - "UNKNOWN"`, and empty data arrays. - -Retry the same `GET /tracking` request after a pending response. The gateway -reuses the existing tracking request instead of creating another one. Terminal49 -failure reasons are translated to SeaRates-style messages such as +4. returns SeaRates' successful empty-data outcome: + `status: "success"`, `message: "SEALINE_HASNT_PROVIDE_INFO"`, + `metadata.status: "UNKNOWN"`, and empty data arrays. + +SeaRates has no documented pending response, so the gateway does not invent one. +Retry the same `GET /tracking` request after the empty-data outcome. The gateway +reuses the existing Terminal49 tracking request instead of creating another one. +Terminal49 failure reasons are translated to SeaRates-style messages such as `WRONG_NUMBER`, `AUTO_CANT_DETECT_SEALINE`, and `SEALINE_HASNT_PROVIDE_INFO`. diff --git a/packages/searates-compat/src/http.test.ts b/packages/searates-compat/src/http.test.ts index 2744533a..1d556d98 100644 --- a/packages/searates-compat/src/http.test.ts +++ b/packages/searates-compat/src/http.test.ts @@ -94,7 +94,7 @@ describe('GET /tracking contract', () => { expect(output.body).toEqual({ status: 'error', message: 'WRONG_TYPE', - data: null, + data: {}, }); }); }); diff --git a/packages/searates-compat/src/http.ts b/packages/searates-compat/src/http.ts index 291aaa94..405709b1 100644 --- a/packages/searates-compat/src/http.ts +++ b/packages/searates-compat/src/http.ts @@ -75,7 +75,7 @@ export function createTrackingHandler( response.status(405).json({ status: 'error', message: 'METHOD_NOT_ALLOWED', - data: null, + data: {}, }); return; } @@ -90,7 +90,7 @@ export function createTrackingHandler( response.status(200).json({ status: 'error', message: 'WRONG_TYPE', - data: null, + data: {}, }); return; } @@ -124,7 +124,7 @@ export function createShippingLinesHandler( response.status(405).json({ status: 'error', message: 'METHOD_NOT_ALLOWED', - data: null, + data: {}, }); return; } diff --git a/packages/searates-compat/src/index.ts b/packages/searates-compat/src/index.ts index 63f5878a..115768c1 100644 --- a/packages/searates-compat/src/index.ts +++ b/packages/searates-compat/src/index.ts @@ -5,10 +5,10 @@ export { } from './client.js'; export { createShippingLinesHandler, createTrackingHandler } from './http.js'; export { + emptyTrackingEnvelope, mapEvent, mapShippingLines, mapTrackingPayload, - pendingEnvelope, } from './mapping.js'; export { SeaRatesCompatibilityGateway, type GatewayConfig } from './service.js'; export type { diff --git a/packages/searates-compat/src/mapping.test.ts b/packages/searates-compat/src/mapping.test.ts index 56bc180b..400d9667 100644 --- a/packages/searates-compat/src/mapping.test.ts +++ b/packages/searates-compat/src/mapping.test.ts @@ -64,7 +64,7 @@ describe('SeaRates response mapping', () => { }); it.each([ - ['container.transport.empty_out', 'GTOT', 'CEP'], + ['container.transport.empty_out', 'PICK', 'CPS'], ['container.transport.full_in', 'GTIN', 'CGI'], ['container.transport.vessel_loaded', 'LOAD', 'CLL'], ['container.transport.vessel_departed', 'DEPA', 'VDL'], @@ -99,6 +99,7 @@ describe('SeaRates response mapping', () => { data: [ { name: 'Mediterranean Shipping Company', + short_name: 'MSC', active: true, active_types: { ct: true, diff --git a/packages/searates-compat/src/mapping.ts b/packages/searates-compat/src/mapping.ts index 3fb7093c..deb5c93a 100644 --- a/packages/searates-compat/src/mapping.ts +++ b/packages/searates-compat/src/mapping.ts @@ -18,10 +18,10 @@ interface EventMapping { const EVENT_MAPPINGS: Record = { 'container.transport.empty_out': { - code: 'GTOT', + code: 'PICK', description: 'Empty Picked-up at Depot', eventType: 'EQUIPMENT', - status: 'CEP', + status: 'CPS', transport: 'TRUCK', }, 'container.transport.full_in': { @@ -206,7 +206,7 @@ function equipment(attributes: JsonObject): { function seaRatesStatus(value: unknown): string { if (typeof value !== 'string') return 'UNKNOWN'; if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { - return 'COMPLETED'; + return 'DELIVERED'; } if ( [ @@ -311,8 +311,8 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { ); const overallStatus = containerStatuses.includes('IN_TRANSIT') ? 'IN_TRANSIT' - : containerStatuses.includes('COMPLETED') - ? 'COMPLETED' + : containerStatuses.includes('DELIVERED') + ? 'DELIVERED' : 'UNKNOWN'; const locationList = locationResources.map((resource) => { @@ -470,14 +470,14 @@ export function mapTrackingPayload(payload: TrackingPayload): SeaRatesEnvelope { }; } -export function pendingEnvelope( +export function emptyTrackingEnvelope( number: string, type?: TrackingType, sealine?: string, ): SeaRatesEnvelope { return { status: 'success', - message: 'PENDING', + message: 'SEALINE_HASNT_PROVIDE_INFO', data: { metadata: { type: defaultType(number, type), @@ -529,6 +529,7 @@ export function mapShippingLines(document: JsonApiDocument): SeaRatesEnvelope { : alternatives; return { name: stringValue(attributes.name), + short_name: stringValue(attributes.short_name), active: true, active_types: { ct: attributes.container_number_tracking_support === true, diff --git a/packages/searates-compat/src/service.test.ts b/packages/searates-compat/src/service.test.ts index 8cd22cfc..dc8718aa 100644 --- a/packages/searates-compat/src/service.test.ts +++ b/packages/searates-compat/src/service.test.ts @@ -67,13 +67,13 @@ describe('SeaRates compatibility gateway', () => { }); await expect(gateway.tracking(undefined, query)).resolves.toEqual({ status: 'error', - message: 'API_KEY_REQUIRED', - data: null, + message: 'WRONG_PARAMETERS', + data: {}, }); await expect(gateway.tracking('wrong-key', query)).resolves.toEqual({ status: 'error', message: 'API_KEY_WRONG', - data: null, + data: {}, }); }); @@ -91,7 +91,41 @@ describe('SeaRates compatibility gateway', () => { await expect(gateway.tracking('bad-t49-key', query)).resolves.toEqual({ status: 'error', message: 'API_KEY_WRONG', - data: null, + data: {}, + }); + }); + + it('uses SeaRates empty-data semantics while T49 is still pending', async () => { + const gateway = new SeaRatesCompatibilityGateway({ + pollTimeoutMs: 0, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes('/shipments?')) return response({ data: [] }); + if (url.includes('/tracking_requests?')) { + return response({ + data: [ + { + id: 'request-1', + type: 'tracking_request', + attributes: { status: 'pending' }, + relationships: { tracked_object: { data: null } }, + }, + ], + }); + } + throw new Error(`Unexpected fixture request: ${url}`); + }, + }); + + await expect( + gateway.tracking('pass-through-key', query), + ).resolves.toMatchObject({ + status: 'success', + message: 'SEALINE_HASNT_PROVIDE_INFO', + data: { + metadata: { status: 'UNKNOWN' }, + containers: [], + }, }); }); diff --git a/packages/searates-compat/src/service.ts b/packages/searates-compat/src/service.ts index d29a24da..911bab25 100644 --- a/packages/searates-compat/src/service.ts +++ b/packages/searates-compat/src/service.ts @@ -5,9 +5,9 @@ import { type Terminal49ClientConfig, } from './client.js'; import { + emptyTrackingEnvelope, mapShippingLines, mapTrackingPayload, - pendingEnvelope, } from './mapping.js'; import type { JsonApiDocument, @@ -28,7 +28,7 @@ export interface GatewayConfig { } function errorEnvelope(message: string): SeaRatesEnvelope { - return { status: 'error', message, data: null }; + return { status: 'error', message, data: {} }; } function secureEqual(left: string, right: string): boolean { @@ -78,7 +78,7 @@ export class SeaRatesCompatibilityGateway { apiKey: string | undefined, query: TrackingQuery, ): Promise { - if (!apiKey) return errorEnvelope('API_KEY_REQUIRED'); + if (!apiKey) return errorEnvelope('WRONG_PARAMETERS'); if (!query.number) return errorEnvelope('WRONG_NUMBER'); let client: Terminal49PublicClient; @@ -101,22 +101,24 @@ export class SeaRatesCompatibilityGateway { type, }); if (resolution.state === 'failed') { - const message = - resolution.failedReason === 'scac_auto_detect_failed' - ? 'AUTO_CANT_DETECT_SEALINE' - : resolution.failedReason === 'invalid_number' - ? 'WRONG_NUMBER' - : 'SEALINE_HASNT_PROVIDE_INFO'; - return errorEnvelope(message); + if (resolution.failedReason === 'scac_auto_detect_failed') { + return errorEnvelope('AUTO_CANT_DETECT_SEALINE'); + } + if (resolution.failedReason === 'invalid_number') { + return errorEnvelope('WRONG_NUMBER'); + } + return emptyTrackingEnvelope(query.number, type, query.sealine); } if (resolution.state === 'pending') { - return pendingEnvelope(query.number, type, query.sealine); + return emptyTrackingEnvelope(query.number, type, query.sealine); } shipmentDocument = await client.shipment(resolution.shipmentId); } const shipment = shipmentFrom(shipmentDocument); - if (!shipment) return pendingEnvelope(query.number, type, query.sealine); + if (!shipment) { + return emptyTrackingEnvelope(query.number, type, query.sealine); + } let containers = containerResources(shipmentDocument); if (query.forceUpdate && containers.length > 0) { @@ -158,7 +160,7 @@ export class SeaRatesCompatibilityGateway { try { client = this.client(apiKey, true); } catch { - return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'API_KEY_REQUIRED'); + return errorEnvelope(apiKey ? 'API_KEY_WRONG' : 'WRONG_PARAMETERS'); } try { return mapShippingLines(await client.shippingLines()); From ac228f8c01ae78227387c94ce61a2297318e2029 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 20:11:51 +0000 Subject: [PATCH 5/5] refactor: isolate migration APIs in dedicated Vercel app Co-authored-by: Akshay Dodeja --- .env.local.example | 10 ------ .github/workflows/ci.yml | 28 +++++++++++++++ api/info/sealines.ts | 3 -- api/tracking.ts | 3 -- apps/migrate/.env.example | 11 ++++++ apps/migrate/api/info/sealines.ts | 3 ++ apps/migrate/api/tracking.ts | 3 ++ apps/migrate/package.json | 25 ++++++++++++++ apps/migrate/tsconfig.json | 17 +++++++++ apps/migrate/vercel.json | 24 +++++++++++++ package-lock.json | 20 +++++++++++ package.json | 3 +- packages/searates-compat/README.md | 55 +++++++++++++++++++++++++----- vercel.json | 16 +-------- 14 files changed, 181 insertions(+), 40 deletions(-) delete mode 100644 api/info/sealines.ts delete mode 100644 api/tracking.ts create mode 100644 apps/migrate/.env.example create mode 100644 apps/migrate/api/info/sealines.ts create mode 100644 apps/migrate/api/tracking.ts create mode 100644 apps/migrate/package.json create mode 100644 apps/migrate/tsconfig.json create mode 100644 apps/migrate/vercel.json diff --git a/.env.local.example b/.env.local.example index 0e867cfd..ad7cbf4e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,13 +19,3 @@ T49_API_BASE_URL=http://localhost:3000/v2 # POSTHOG_PROJECT_API_KEY=phc_... # POSTHOG_HOST=https://f.terminal49.com # POSTHOG_DEBUG=true - -# --- SeaRates ocean-tracking compatibility gateway --- -# Pass-through mode (default): leave these unset and send a Terminal49 API key -# in the SeaRates-compatible `api_key` query parameter. -# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`, and -# the gateway uses T49_SEARATES_API_TOKEN only for public T49 API requests. -# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY -# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY -# T49_SEARATES_POLL_TIMEOUT_MS=4000 -# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f44bcf..4e616dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,3 +118,31 @@ jobs: run: npm run lint --workspace @terminal49/mcp - name: Check API gateway (Vite+ and anti-slop) run: npm run lint:api + + migrate: + runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: package-lock.json + - name: Install workspace dependencies + run: npm ci + - name: Build compatibility package + run: npm run build --workspace @terminal49/searates-compat + - name: Build migrate application + run: npm run build --workspace @terminal49/migrate-app + - name: Type-check compatibility package and migrate application + run: | + npm run type-check --workspace @terminal49/searates-compat + npm run type-check --workspace @terminal49/migrate-app + - name: Test compatibility package and migrate configuration + run: | + npm run test --workspace @terminal49/searates-compat + npm run test --workspace @terminal49/migrate-app + - name: Check compatibility package and migrate handlers + run: | + npm run lint --workspace @terminal49/searates-compat + npm run lint --workspace @terminal49/migrate-app diff --git a/api/info/sealines.ts b/api/info/sealines.ts deleted file mode 100644 index 08db7046..00000000 --- a/api/info/sealines.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createShippingLinesHandler } from '../../packages/searates-compat/src/http.js'; - -export default createShippingLinesHandler(); diff --git a/api/tracking.ts b/api/tracking.ts deleted file mode 100644 index fba878cb..00000000 --- a/api/tracking.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createTrackingHandler } from '../packages/searates-compat/src/http.js'; - -export default createTrackingHandler(); diff --git a/apps/migrate/.env.example b/apps/migrate/.env.example new file mode 100644 index 00000000..8a9c6175 --- /dev/null +++ b/apps/migrate/.env.example @@ -0,0 +1,11 @@ +# Pass-through mode is the default: leave service-token variables unset and +# send a Terminal49 API key in the SeaRates-compatible `api_key` query. +T49_API_BASE_URL=https://api.terminal49.com/v2 + +# Service-token mode: clients send T49_SEARATES_CLIENT_SECRET as `api_key`. +# T49_SEARATES_API_TOKEN=YOUR_T49_API_KEY +# T49_SEARATES_CLIENT_SECRET=YOUR_GATEWAY_KEY + +# Optional bounded polling settings. +# T49_SEARATES_POLL_TIMEOUT_MS=4000 +# T49_SEARATES_POLL_INTERVAL_MS=500 diff --git a/apps/migrate/api/info/sealines.ts b/apps/migrate/api/info/sealines.ts new file mode 100644 index 00000000..0cfd5a54 --- /dev/null +++ b/apps/migrate/api/info/sealines.ts @@ -0,0 +1,3 @@ +import { createShippingLinesHandler } from '@terminal49/searates-compat'; + +export default createShippingLinesHandler(); diff --git a/apps/migrate/api/tracking.ts b/apps/migrate/api/tracking.ts new file mode 100644 index 00000000..2641d683 --- /dev/null +++ b/apps/migrate/api/tracking.ts @@ -0,0 +1,3 @@ +import { createTrackingHandler } from '@terminal49/searates-compat'; + +export default createTrackingHandler(); diff --git a/apps/migrate/package.json b/apps/migrate/package.json new file mode 100644 index 00000000..aea88473 --- /dev/null +++ b/apps/migrate/package.json @@ -0,0 +1,25 @@ +{ + "name": "@terminal49/migrate-app", + "version": "0.1.0", + "private": true, + "description": "Vercel application for vendor compatibility APIs", + "type": "module", + "scripts": { + "build": "tsc --noEmit", + "test": "node -e \"JSON.parse(require('fs').readFileSync('vercel.json', 'utf8'))\"", + "lint": "vp lint api && vp fmt --check api", + "format": "vp fmt --write api", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@terminal49/searates-compat": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } +} diff --git a/apps/migrate/tsconfig.json b/apps/migrate/tsconfig.json new file mode 100644 index 00000000..20ce48fe --- /dev/null +++ b/apps/migrate/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "lib": ["ES2022"], + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["api/**/*"], + "exclude": ["node_modules"] +} diff --git a/apps/migrate/vercel.json b/apps/migrate/vercel.json new file mode 100644 index 00000000..79c6d42c --- /dev/null +++ b/apps/migrate/vercel.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "version": 2, + "installCommand": "cd ../.. && npm ci", + "buildCommand": "cd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app", + "functions": { + "api/tracking.ts": { + "maxDuration": 30 + }, + "api/info/sealines.ts": { + "maxDuration": 15 + } + }, + "rewrites": [ + { + "source": "/searates-api/tracking", + "destination": "/api/tracking" + }, + { + "source": "/searates-api/info/sealines", + "destination": "/api/info/sealines" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 6d1a89a8..62873c73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "terminal49-api", "version": "1.0.0", "workspaces": [ + "apps/*", "packages/*", "sdks/*" ], @@ -26,6 +27,21 @@ "node": "24.x" } }, + "apps/migrate": { + "name": "@terminal49/migrate-app", + "version": "0.1.0", + "dependencies": { + "@terminal49/searates-compat": "0.1.0" + }, + "devDependencies": { + "@types/node": "^24.10.13", + "typescript": "^5.6.3", + "vite-plus": "0.2.9" + }, + "engines": { + "node": "24.x" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", @@ -5264,6 +5280,10 @@ "resolved": "packages/mcp", "link": true }, + "node_modules/@terminal49/migrate-app": { + "resolved": "apps/migrate", + "link": true + }, "node_modules/@terminal49/sdk": { "resolved": "sdks/typescript-sdk", "link": true diff --git a/package.json b/package.json index a42ead26..88a63102 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "private": true, "description": "Terminal49 API with MCP Server", "workspaces": [ + "apps/*", "packages/*", "sdks/*" ], "type": "module", "scripts": { - "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/cli", + "build": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app && npm run build --workspace @terminal49/cli", "check": "npm run lint:api && npm run lint --workspaces && npm run type-check --workspaces", "format": "vp fmt --write api && npm run format --workspaces", "lint:api": "vp lint api && vp fmt --check api", diff --git a/packages/searates-compat/README.md b/packages/searates-compat/README.md index 7c96c507..eb3b4669 100644 --- a/packages/searates-compat/README.md +++ b/packages/searates-compat/README.md @@ -8,13 +8,23 @@ System (AIS) products. ## Endpoints -- `GET /tracking` -- `GET /info/sealines` +- `GET /searates-api/tracking` +- `GET /searates-api/info/sealines` -The Vercel deployment maps these paths to `api/tracking.ts` and -`api/info/sealines.ts`. `/info/terminals` is intentionally omitted because the -Terminal49 public API can fetch a known terminal but does not provide a -supported-terminals list. +The dedicated migrate Vercel application in `apps/migrate` maps these paths to +its tracking and sealines handlers. The vendor-prefixed layout leaves room for +future compatibility APIs without placing them on the MCP application. +`/info/terminals` is intentionally omitted because the Terminal49 public API can +fetch a known terminal but does not provide a supported-terminals list. + +The intended production URLs are: + +- `https://migrate.terminal49.com/searates-api/tracking` +- `https://migrate.terminal49.com/searates-api/info/sealines` + +The custom domain is not live yet. Until DNS and the production domain are +configured, deployments use their Vercel preview hostname with the same +`/searates-api/...` paths. ## Configure authentication @@ -53,7 +63,7 @@ T49_SEARATES_POLL_INTERVAL_MS=500 Change the SeaRates base URL and keep the existing query parameters: ```bash -curl "https://YOUR_GATEWAY.example.com/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" +curl "https://migrate.terminal49.com/searates-api/tracking?api_key=YOUR_GATEWAY_KEY&number=MSCU1234567&type=CT&sealine=MSCU" ``` The gateway accepts `type=CT`, `type=BL`, and `type=BK`, plus `force_update`, @@ -65,7 +75,7 @@ implemented. Fetch the carrier dictionary with: ```bash -curl "https://YOUR_GATEWAY.example.com/info/sealines?api_key=YOUR_GATEWAY_KEY" +curl "https://migrate.terminal49.com/searates-api/info/sealines?api_key=YOUR_GATEWAY_KEY" ``` In service-token mode, `/info/sealines` also works without `api_key`, matching @@ -101,3 +111,32 @@ Terminal49 failure reasons are translated to SeaRates-style messages such as flat-rack, hard-top, and tank combinations. Unknown combinations are `null`. - Holds, fees, Last Free Day (LFD), and other Terminal49-only terminal intelligence are deliberately excluded. + +## Create the dedicated Vercel project + +Create a second Vercel project in the Terminal49 team and import this same +repository. This is a dashboard setup step; CI does not create or configure the +project. + +Use these project settings: + +| Setting | Value | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Root Directory | `apps/migrate` | +| Include source files outside the Root Directory | Enabled | +| Framework Preset | Other | +| Install Command | `cd ../.. && npm ci` | +| Build Command | `cd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-app` | +| Node.js Version | 24 | + +The outside-root source setting is required because the app consumes the +`@terminal49/searates-compat` workspace from `packages/searates-compat`. + +Configure either pass-through mode or the service-token environment variables +described above in the new project. Do not copy them into the MCP Vercel +project. + +After the project has a successful production deployment and DNS is ready, add +`migrate.terminal49.com` under the project's production domains. Vercel will +show the DNS record that must be added; do not assume the domain is active until +Vercel verifies it. diff --git a/vercel.json b/vercel.json index f8752374..8000aea7 100644 --- a/vercel.json +++ b/vercel.json @@ -2,17 +2,11 @@ "$schema": "https://openapi.vercel.sh/vercel.json", "version": 2, "installCommand": "npm ci", - "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp && npm run build --workspace @terminal49/searates-compat", + "buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp", "functions": { "api/mcp.ts": { "maxDuration": 30 }, - "api/tracking.ts": { - "maxDuration": 30 - }, - "api/info/sealines.ts": { - "maxDuration": 15 - }, "api/oauth-protected-resource.ts": { "maxDuration": 10 }, @@ -21,14 +15,6 @@ } }, "rewrites": [ - { - "source": "/tracking", - "destination": "/api/tracking" - }, - { - "source": "/info/sealines", - "destination": "/api/info/sealines" - }, { "source": "/.well-known/oauth-protected-resource", "destination": "/api/oauth-protected-resource"