feat: add SeaRates ocean tracking compatibility gateway - #331
Conversation
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
Co-authored-by: Akshay Dodeja <dodeja@gmail.com>
| 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 'DELIVERED'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) |
There was a problem hiding this comment.
Active statuses become unknown
When a container has a valid active Terminal49 status such as grounded, on_rail, off_dock, dropped, or loaded, this function falls through to UNKNOWN, causing both the container and potentially the shipment metadata to report an indeterminate status despite active tracking.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 201-218
Comment:
**Active statuses become unknown**
When a container has a valid active Terminal49 status such as `grounded`, `on_rail`, `off_dock`, `dropped`, or `loaded`, this function falls through to `UNKNOWN`, causing both the container and potentially the shipment metadata to report an indeterminate status despite active tracking.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| number: | ||
| stringValue(shipmentAttributes.bill_of_lading_number) || | ||
| payload.requestedNumber, |
There was a problem hiding this comment.
Requested tracking number is replaced
When a CT or BK request resolves to a shipment with a bill-of-lading number, this expression reports that BOL instead of the requested identifier, causing clients to correlate, display, or cache the response under the wrong tracking number.
| number: | |
| stringValue(shipmentAttributes.bill_of_lading_number) || | |
| payload.requestedNumber, | |
| number: payload.requestedNumber, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 412-414
Comment:
**Requested tracking number is replaced**
When a CT or BK request resolves to a shipment with a bill-of-lading number, this expression reports that BOL instead of the requested identifier, causing clients to correlate, display, or cache the response under the wrong tracking number.
```suggestion
number: payload.requestedNumber,
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| status: mapping.status, | ||
| facility: facilityId ? (ids.facilities.get(facilityId) ?? null) : null, | ||
| is_additional_event: false, | ||
| is_date_from_sealine: attributes.data_source === 'shipping_line', |
There was a problem hiding this comment.
Event provenance uses absent field
Public transport-event responses do not provide the data_source attribute checked here, so real events are always emitted with is_date_from_sealine: false, producing incorrect provenance despite the fixture supplying the undocumented field.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/searates-compat/src/mapping.ts
Line: 274
Comment:
**Event provenance uses absent field**
Public transport-event responses do not provide the `data_source` attribute checked here, so real events are always emitted with `is_date_from_sealine: false`, producing incorrect provenance despite the fixture supplying the undocumented field.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac228f8c01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, |
There was a problem hiding this comment.
Send API keys with the Token scheme
In both pass-through and service-token modes, the credential is a Terminal49 API key, but every upstream request is sent as Authorization: Bearer .... Terminal49 API keys require the Token scheme (the existing SDK only preserves Bearer for explicitly supplied OAuth tokens), so valid gateway credentials receive 401 responses and tracking is returned as API_KEY_WRONG. Use Token for these API-key-backed requests.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) |
There was a problem hiding this comment.
Map active container statuses to IN_TRANSIT
When Terminal49 returns common active statuses such as grounded, on_rail, off_dock, or loaded, this predicate falls through to UNKNOWN; in_transit, meanwhile, is not one of the documented Terminal49 current_status values. Consequently both containers[].status and the aggregate metadata status become UNKNOWN during substantial portions of a shipment's journey instead of IN_TRANSIT.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
| dry: 'G1', | ||
| flat_rack: 'P1', | ||
| hard_top: 'U1', |
There was a problem hiding this comment.
Match the API's equipment type values
For open-top and flat-rack containers, the public API returns equipment_type as "open top" and "flat rack", but this lookup only recognizes underscore variants. Those supported containers therefore get iso_code: null and an unnormalized size_type, despite the gateway's stated ISO reconstruction support; normalize the incoming value or key the maps by the schema-defined strings.
AGENTS.md reference: AGENTS.md:L27-L27
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Summary
Reviewed — found 9 issues. This PR adds an isolated Vercel migration app that exposes SeaRates-compatible tracking and shipping-line endpoints backed by Terminal49’s JSON:API. I reviewed workspace and deployment configuration, request authentication, tracking-request resolution and refresh behavior, response mappings, error handling, and unit coverage.
Findings
packages/searates-compat/src/client.ts
- API-key requests use the wrong authorization scheme.
- Tracking-request reuse can select an incompatible or failed historical request.
- Upstream requests have no timeout or abort signal.
packages/searates-compat/src/service.ts
force_updateexceeds the refresh rate limit for shipments with more than ten containers.- Post-refresh responses retain stale shipment attributes.
packages/searates-compat/src/mapping.ts
- Valid journey states map to incorrect SeaRates statuses.
CTandBKresponses can report the bill of lading as the requested number.- Offset timestamps violate the promised SeaRates timestamp shape.
- Public equipment values produce missing or invalid ISO codes.
Verdict
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, |
There was a problem hiding this comment.
Issue — use the API-key authorization scheme. This client receives Terminal49 API keys, but sends them as Authorization: Bearer .... Terminal49 API keys require the Token scheme; Bearer is reserved for user-scoped JWTs and requires account context. As written, valid pass-through keys and T49_SEARATES_API_TOKEN credentials will receive 401 responses. Send API keys as Authorization: Token ... and update the header assertion in the tests.
| 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()}`); |
There was a problem hiding this comment.
Issue — do not reuse an arbitrary historical request. This query returns only one record for the number and optional SCAC, without matching request_type or selecting by status and recency. Multiple requests can exist for the same number, including different request types and failed attempts followed by successful ones, so a BL lookup can reuse a BK/CT request or an old failure and then never create a valid replacement. Retrieve enough candidates to enforce the requested type, prefer a tracked or active request, and create a new request when only failed candidates exist.
| private async request( | ||
| path: string, | ||
| init: RequestInit = {}, | ||
| ): Promise<JsonApiDocument> { | ||
| const response = await this.fetchImpl(`${this.baseUrl}${path}`, { | ||
| ...init, | ||
| headers: { | ||
| Accept: 'application/vnd.api+json', | ||
| Authorization: `Bearer ${this.token}`, | ||
| ...init.headers, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Issue — bound upstream requests with an abort timeout. The polling loop has a nominal deadline, but each fetch can stall beyond that deadline and eventually be terminated by Vercel. That bypasses the gateway's SeaRates error envelope and can affect shipment, event, shipping-line, and polling calls. Add an AbortSignal timeout bounded by the remaining polling/function budget and translate timeout failures to SEALINE_NO_RESPONSE.
| if (query.forceUpdate && containers.length > 0) { | ||
| await Promise.all( | ||
| containers.map((container) => client.refreshContainer(container.id)), | ||
| ); |
There was a problem hiding this comment.
Issue — throttle force_update refreshes. Container refreshes are limited to 10 requests per minute, but Promise.all submits every refresh concurrently. Any shipment with more than ten containers can therefore trigger 429 responses, fail the entire otherwise-valid lookup after partially scheduling refreshes, and immediately consume the shared quota in service-token mode. Queue or throttle these calls to the documented limit and define partial-failure behavior.
| shipmentDocument = await client.shipment(shipment.id); | ||
| containers = containerResources(shipmentDocument); | ||
| } | ||
|
|
||
| const eventsByContainerId = new Map<string, JsonApiDocument>(); | ||
| 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, |
There was a problem hiding this comment.
Issue — replace the shipment resource after refresh. After force_update, this fetches a new shipmentDocument and replaces containers, but the payload still references the pre-refresh shipment. Updated route dates, vessel or carrier metadata, and tracking timestamps from the second response are discarded while newer included containers are used. Re-extract and validate the shipment resource from the refreshed document before constructing TrackingPayload.
| function seaRatesStatus(value: unknown): string { | ||
| if (typeof value !== 'string') return 'UNKNOWN'; | ||
| if (['delivered', 'empty_returned', 'picked_up'].includes(value)) { | ||
| return 'DELIVERED'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'available', | ||
| 'awaiting_inland_transfer', | ||
| 'in_transit', | ||
| 'not_available', | ||
| 'on_ship', | ||
| ].includes(value) | ||
| ) { | ||
| return 'IN_TRANSIT'; | ||
| } | ||
| return 'UNKNOWN'; |
There was a problem hiding this comment.
Issue — map all public journey states correctly. picked_up is classified as DELIVERED even though it represents transport from the terminal to the warehouse, while active states such as grounded, on_rail, off_dock, loaded, and dropped fall through to UNKNOWN; new also lacks a planned-state mapping. This makes container and aggregate shipment status incorrect for common journeys. Keep pickup and active terminal/movement states in transit, reserve delivery for actual delivery or empty return, and explicitly map the pre-departure state.
| type: payload.requestedType, | ||
| number: | ||
| stringValue(shipmentAttributes.bill_of_lading_number) || | ||
| payload.requestedNumber, |
There was a problem hiding this comment.
Issue — preserve the identifier associated with metadata.type. For type=CT or type=BK, this replaces the submitted identifier with bill_of_lading_number, producing metadata whose type and number refer to different identifiers. Clients can then mis-correlate or mis-cache the response. Preserve payload.requestedNumber, or choose the identifier corresponding to requestedType.
| function formatDate(value: unknown): string | null { | ||
| if (typeof value !== 'string' || !value) return null; | ||
| return value.replace('T', ' ').replace(/(?:\.\d+)?Z$/, ''); |
There was a problem hiding this comment.
Issue — normalize offset timestamps to UTC. This only strips a trailing Z; a valid value such as 2020-04-04T16:13:35-07:00 becomes 2020-04-04 16:13:35-07:00, which does not match the documented YYYY-MM-DD HH:MM:SS response shape and is not UTC. Parse valid ISO-8601 values, normalize to UTC, format consistently, and add coverage for both Z and non-zero offsets.
| const first = length === 20 ? '2' : length === 40 ? '4' : 'L'; | ||
| const second = height === 'high_cube' ? '5' : '2'; | ||
| const typeCode: Record<string, string> = { | ||
| dry: 'G1', | ||
| flat_rack: 'P1', | ||
| hard_top: 'U1', | ||
| open_top: 'U1', | ||
| reefer: 'R1', | ||
| tank: 'T1', | ||
| }; | ||
| const label: Record<string, string> = { | ||
| dry: 'Dry', | ||
| flat_rack: 'Flat Rack', | ||
| hard_top: 'Hard Top', | ||
| open_top: 'Open Top', | ||
| reefer: 'Reefer', | ||
| tank: 'Tank', | ||
| }; |
There was a problem hiding this comment.
Issue — handle the public equipment values and lengths. The Terminal49 schema emits equipment types such as open top and flat rack, while this table only recognizes underscore variants, yielding iso_code: null and an unnormalized size_type. The fallback length prefix L is also only appropriate for 45-foot equipment, not supported 10-foot containers, whose ISO length character is 1. Map the actual enum values and handle each supported length explicitly.
Summary
apps/migrateVercel application for vendor compatibility APIs, separate from the MCP deploymentGET /searates-api/trackingandGET /searates-api/info/sealinesroutesSEALINE_HASNT_PROVIDE_INFOempty-data outcomeDeployment architecture
The root
vercel.jsonremains MCP-only:/,/mcp, and OAuth discovery routes still target the MCP handlers. It contains no SeaRates functions or rewrites.Create a second Vercel project in the Terminal49 team from this same repository with:
apps/migratecd ../.. && npm cicd ../.. && npm run build --workspace @terminal49/searates-compat && npm run build --workspace @terminal49/migrate-appConfigure the migrate app's authentication environment variables in that project, not the MCP project. After a successful production deployment and once DNS is ready, add
migrate.terminal49.comas the project's production domain in the Vercel dashboard. The custom domain is not currently live; previews use the Vercel preview hostname with the same/searates-api/...paths. CI does not create or configure the Vercel project.Scope
Ocean tracking and the shipping-lines dictionary only. This does not add rates, schedules, air, parcel, road, historical route, AIS pin, or terminal dictionary products.
/info/terminalsis omitted because the public API has no terminal-list endpoint.Compatibility details
data: {}api_keymaps to verifiedWRONG_PARAMETERS; rejected credentials map toAPI_KEY_WRONGPICK/CPS/shipping_linesVerification
/Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Greptile Summary
The PR adds a dedicated Vercel application and package that expose SeaRates-compatible ocean-tracking and shipping-line endpoints over Terminal49's public JSON:API.
Confidence Score: 2/5
The PR should not merge until tracking responses preserve the requested identifier and correctly represent valid container status and event provenance.
The new mapping layer returns observably incorrect compatibility data for current public API responses: active container states can become unknown, CT/BK metadata can contain a bill-of-lading number, and transport-event provenance is derived from an unavailable attribute.
Files Needing Attention: packages/searates-compat/src/mapping.ts, packages/searates-compat/src/fixtures/t49.ts
Important Files Changed
Sequence Diagram
sequenceDiagram participant C as SeaRates Client participant V as Migrate Vercel Handler participant G as Compatibility Gateway participant T as Terminal49 API C->>V: GET /searates-api/tracking V->>G: tracking(api_key, query) G->>T: Find existing shipment alt Shipment exists T-->>G: Shipment and containers else Shipment missing G->>T: Create or reuse tracking request loop Bounded polling G->>T: Read tracking request T-->>G: Pending, failed, or shipment end end G->>T: Fetch container transport events T-->>G: JSON:API events G-->>V: SeaRates-compatible envelope V-->>C: HTTP 200 JSONPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "refactor: isolate migration APIs in dedi..." | Re-trigger Greptile
Context used: