/* * Public TypeScript client for the STOWAGE.ID API (`https://api.stowage.id/v1/ext`) plus the * webhook signature helpers. Self-contained (WebCrypto + fetch only) so it runs in Node ≥ 18, * Deno, Bun, Cloudflare Workers and browsers. The server signs webhooks with the same * `signWebhook` function, so verification here is the reference implementation. * * const stowage = createExtClient({ apiKey: process.env.STOWAGE_API_KEY! }); * const { result } = await stowage.solve({ space: { code: '40HC' }, items: [...] }); */ export const EXT_BASE_URL = 'https://api.stowage.id/v1/ext'; /* ------------------------------------------------------------------ constants */ export const API_SCOPES = ['plans:read', 'plans:write', 'rfqs:read', 'rfqs:write', 'ports:read', 'vessel:read', 'vessel:write'] as const; export type ApiScope = (typeof API_SCOPES)[number]; export const WEBHOOK_EVENTS = ['plan.completed', 'quote.received', 'plan.approved', 'plan.final', 'stability.alert', 'vgm.missing'] as const; export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; /** Sent only by "Send test" in the dashboard. */ export const WEBHOOK_TEST_EVENT = 'webhook.test'; /** Requests per minute per API key (sliding window). Plans without the `api` feature get 0. */ export const API_RATE_LIMITS: Record = { business: 60, enterprise: 600 }; /** Synchronous solve limit (total units = Σ qty). Larger loads → Advanced Optimisation in the Studio. */ export const MAX_SYNC_UNITS = 500; export const API_KEY_PREFIX = 'stw_live_'; export const SIGNATURE_HEADER = 'Stowage-Signature'; /** Default replay window for webhook verification, seconds. */ export const SIGNATURE_TOLERANCE_SEC = 300; /* ------------------------------------------------------------------ webhook signatures */ const enc = new TextEncoder(); async function hmacSha256Hex(secret: string, data: string): Promise { const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data)); return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join(''); } function safeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let r = 0; for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); return r === 0; } /** `Stowage-Signature` header value: `t=,v1=.")>`. */ export async function signWebhook(secret: string, rawBody: string, timestampSec: number = Math.floor(Date.now() / 1000)): Promise { const v1 = await hmacSha256Hex(secret, `${timestampSec}.${rawBody}`); return `t=${timestampSec},v1=${v1}`; } export function parseSignatureHeader(header: string | null | undefined): { t: number; v1: string[] } | null { if (!header) return null; let t = NaN; const v1: string[] = []; for (const part of header.split(',')) { const i = part.indexOf('='); if (i < 0) continue; const k = part.slice(0, i).trim(); const v = part.slice(i + 1).trim(); if (k === 't') t = Number(v); else if (k === 'v1' && /^[0-9a-f]{64}$/.test(v)) v1.push(v); } return Number.isFinite(t) && v1.length ? { t, v1 } : null; } export interface VerifyOptions { /** The endpoint secret (`whsec_…`) shown when the endpoint was created. */ secret: string; /** The RAW request body exactly as received (do not re-serialise parsed JSON). */ body: string; /** Value of the `Stowage-Signature` header. */ header: string | null | undefined; toleranceSec?: number; /** Override "now" (unix seconds), for tests. */ nowSec?: number; } /** True when the signature matches and the timestamp is within the tolerance (replay protection). */ export async function verifyWebhookSignature(o: VerifyOptions): Promise { const parsed = parseSignatureHeader(o.header); if (!parsed) return false; const now = o.nowSec ?? Math.floor(Date.now() / 1000); if (Math.abs(now - parsed.t) > (o.toleranceSec ?? SIGNATURE_TOLERANCE_SEC)) return false; const expected = await hmacSha256Hex(o.secret, `${parsed.t}.${o.body}`); return parsed.v1.some((s) => safeEqual(s, expected)); } /* ------------------------------------------------------------------ types */ export interface ExtCargoItem { /** Your line id (defaults to sku or a generated id). */ id?: string; sku?: string; name?: string; /** Millimetres. */ l: number; w: number; h: number; /** Kilograms per unit. */ kg: number; qty: number; stackable?: boolean; orientation?: 'all' | 'upright' | 'fixed'; maxTopKg?: number | null; fragile?: boolean; group?: string | null; /** 1 = unloaded first (loaded last, nearest the door). */ unloadOrder?: number | null; } export type ExtSpace = { code: string } | { custom: { name?: string; l: number; w: number; h: number; maxPayloadKg: number } }; export interface SolveInput { title?: string; space: ExtSpace; items: ExtCargoItem[]; options?: { minSupport?: number; respectUnloadOrder?: boolean; heavyBottom?: boolean }; /** Also store the result as a load plan (uses the `plans` quota) and emit `plan.completed`. */ save?: boolean; /** Include every placement (x/y/z per unit) in the response. Default true. */ includePlacements?: boolean; } export interface ExtPlacement { uid: string; itemId: string; x: number; y: number; z: number; l: number; w: number; h: number; kg: number; step: number; } export interface SolveResult { space: { code: string; name: string; kind: string; l: number; w: number; h: number; maxPayloadKg: number }; utilizationPct: number; weightUtilizationPct: number; placedUnits: number; unplacedUnits: number; cargoKg: number; unplaced: { itemId: string; qty: number; reason: string }[]; warnings: { code: string; severity: 'info' | 'warn' | 'error' }[]; steps: number; strategy: string; durationMs: number; placements?: ExtPlacement[]; } export interface ExtPlan { id: string; title: string; mode: string; spaceCode: string; status: string; approvalStatus: string | null; utilizationPct: number; weightUtilizationPct: number; totalKg: number; itemCount: number; unitCount: number; version: number; appUrl: string; createdAt: number; updatedAt: number; } export interface SolveResponse { result: SolveResult; plan: ExtPlan | null; } export interface ExtRfqInput { title: string; originLocode: string; destLocode: string; mode: 'FCL' | 'LCL' | 'air' | 'truck'; incoterm: 'EXW' | 'FCA' | 'FAS' | 'FOB' | 'CFR' | 'CIF' | 'CPT' | 'CIP' | 'DAP' | 'DPU' | 'DDP'; readyDate: string; planId?: string; cargo?: { cbm: number; kg: number; packages: number; containers?: string; description?: string }; commodity?: string; notes?: string; contact: { name: string; email: string; phone?: string }; expiresInDays?: number; } export interface ExtRfq { id: string; title: string; originLocode: string; destLocode: string; mode: string; incoterm: string; readyDate: string; planId: string | null; status: string; expiresAt: number; createdAt: number; quotes?: { id: string; vendorId: string; amountMinor: number; currency: string; transitDays: number | null; validUntil: string; status: string }[]; } export interface ExtPort { locode: string; name: string; country: string; subdivision: string | null; isSeaport: boolean; isAirport: boolean; lat: number | null; lng: number | null; } export interface ExtCatalog { containers: { code: string; isoCode: string; name: string; inner: { l: number; w: number; h: number }; door: { w: number; h: number } | null; maxPayloadKg: number; tareKg: number; capacityL: number; teu: number }[]; trucks: { code: string; name: string; class: string; body: string; deck: { l: number; w: number; h: number }; payloadKg: number; jbbKg: number; axles: number }[]; pallets: { code: string; name: string; base: { l: number; w: number }; heightMm: number; maxLoadKg: number; defaultMaxHeightMm: number }[]; ulds: { code: string; iata: string; name: string; base: { l: number; w: number }; heightMm: number; maxGrossKg: number; tareKg: number }[]; } /** Body of every webhook POST. */ export interface WebhookEnvelope> { id: string; type: WebhookEvent | typeof WEBHOOK_TEST_EVENT; createdAt: number; orgId: string; data: T; } /* ------------------------------------------------------------------ client */ export class StowageExtError extends Error { constructor( public status: number, public code: string, message: string, public details?: unknown, public retryAfterSec?: number, ) { super(message); this.name = 'StowageExtError'; } } export interface ExtClientOptions { apiKey: string; baseUrl?: string; fetch?: typeof fetch; /** Locale of names in catalog responses. */ locale?: 'id' | 'en'; } export function createExtClient(opts: ExtClientOptions) { const base = (opts.baseUrl ?? EXT_BASE_URL).replace(/\/+$/, ''); const f = opts.fetch ?? fetch.bind(globalThis); async function request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { const headers: Record = { authorization: `Bearer ${opts.apiKey}`, accept: 'application/json' }; if (opts.locale) headers['accept-language'] = opts.locale; if (body !== undefined) headers['content-type'] = 'application/json'; const res = await f(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); const text = await res.text(); const data: unknown = text ? JSON.parse(text) : undefined; if (!res.ok) { const err = (data as { error?: { code?: string; message?: string; details?: unknown } } | undefined)?.error; const retry = Number(res.headers.get('retry-after') ?? ''); throw new StowageExtError(res.status, err?.code ?? `http_${res.status}`, err?.message ?? res.statusText, err?.details, Number.isFinite(retry) && retry > 0 ? retry : undefined); } return data as T; } const qs = (p: Record) => { const s = new URLSearchParams(); for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== '') s.set(k, String(v)); const out = s.toString(); return out ? `?${out}` : ''; }; return { request, /** Pack the cargo synchronously (≤ 500 units). */ solve: (input: SolveInput) => request('POST', '/plans/solve', input), getPlan: (id: string, opts2: { document?: boolean } = {}) => request<{ plan: ExtPlan; document?: unknown }>('GET', `/plans/${encodeURIComponent(id)}${qs({ document: opts2.document ? 1 : undefined })}`), createRfq: (input: ExtRfqInput) => request<{ rfq: ExtRfq; invited: number }>('POST', '/rfqs', input), getRfq: (id: string) => request<{ rfq: ExtRfq }>('GET', `/rfqs/${encodeURIComponent(id)}`), ports: (q: { q?: string; country?: string; seaport?: boolean; limit?: number } = {}) => request<{ items: ExtPort[] }>('GET', `/ports${qs(q)}`), catalog: (kind?: 'containers' | 'trucks' | 'pallets' | 'ulds') => request>('GET', `/containers${qs({ kind })}`), }; } export type StowageExtClient = ReturnType;