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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ coverage/
.env.*.local
.npmrc
*.tgz
.claude/
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **Webhook signature header hardening.** `Webhooks.constructEvent` now joins multi-value `Langos-Signature` headers (string[] from proxies that split duplicate headers) with `,` so every `v1=` entry from every copy is considered — previously only `[0]` was used and signatures from rotation copies were silently dropped. Empty arrays now throw a clear `LangosSignatureVerificationError` instead of crashing on `[0]!`. Signature headers longer than 4096 characters are rejected before parsing as a CPU-DoS guard against attacker-controlled input.

### Changed
- **Retry-After cap raised from 32s to 5 minutes** and made configurable via the new `maxRetryAfterMs` client option. The previous cap silently truncated realistic rate-limit windows (60–300s), defeating the header. Server-supplied values that are negative, NaN, or exceed the cap now fall back to exponential backoff (rather than silently honoring an attacker-controlled "sleep for 3 years" value).
- **`Webhooks.constructEvent` no longer conflates malformed-JSON payloads with signature failure.** Bodies that pass HMAC verification but fail `JSON.parse` now throw the new `LangosWebhookPayloadError` instead of `LangosSignatureVerificationError` — the two have different operational responses (file an upstream ticket vs rotate the secret).

### Tests
- **`LangosTimeoutError` path**: four tests covering configured timeout, `timeoutMs` value, message content, and per-call `RequestOptions.timeout` override
- **`LangosConnectionError` path**: four tests covering DNS/refused errors, `.cause` propagation, message content, and retry exhaustion (3 total fetch calls on `maxRetries: 2`)
- **`AbortSignal` propagation**: three tests — pre-aborted signal, mid-flight abort, no-retry when signal fires — documenting current raw-throw behaviour (see real bug fix below)
- **Idempotency-Key reuse across retries**: three tests verifying the same auto-generated UUID is sent on every POST attempt, user-supplied key is preserved, and independent calls get distinct keys
- **Retry-After parsing**: eight tests — numeric (small values, capped per `maxRetryAfterMs`), HTTP-date (falls back to exponential), negative, NaN, missing, and two integration tests

### Added
- **`LangosAbortError`.** New error class thrown when a partner-supplied `AbortSignal` cancels an in-flight request. Previously the SDK re-threw the raw `AbortError` DOMException, making cancellation indistinguishable from generic network failures.
- **`LangosResponseFormatError`.** New error class thrown when a 2xx response has a non-JSON content-type (e.g. SPA HTML at a misconfigured baseUrl). Prevents the SDK from silently casting garbage into resource objects.
- **`LangosWebhookPayloadError`.** New error class for webhook payloads that pass signature verification but cannot be parsed as JSON. Re-exported from the package root.
- **`maxRetryAfterMs` client option.** Configurable upper bound (default `300_000`) on how long the SDK will wait when honoring a `Retry-After` header.
- **`client.challenges` resource.** `list({status, language, limit, cursor})` and `retrieve(id)` for the read-only `/v1/challenges` and `/v1/challenges/:id` endpoints. Lets partners discover available coding challenges in the customer's library before assigning them to candidates.
- **`Challenge`, `ChallengeListParams`, `ChallengeStatus` types.** Re-exported from the package root.
- **`challengeFromWire` transform** with unit-test coverage for both fully-populated and minimum-fields shapes.
- **`BillingCycle` and `AccountStatus` exported types.** Narrowed unions matching the server contract; partners can now discriminate on these without retyping enum literals.

### Changed
- **Eliminated `any` from wire-handling code.** Every `*FromWire` transform and every `this.get<...>` / `this.post<...>` resource call is now typed against an internal `Wire<Resource>` interface (declared in `src/core/transform.ts`). The SDK no longer takes raw `any` from the HTTP boundary; mismatches surface at compile time.
- **Narrowed `Account.billingCycle`.** Was `string`; now `BillingCycle | null` where `BillingCycle = 'monthly' | 'yearly'`. The server emits `'monthly'` or `'yearly'` (Stripe-aligned); free-tier accounts with no Stripe subscription map to `null`. **Forward-compat:** unknown future values (e.g. `'quarterly'`) collapse to `null` rather than throw.
- **Narrowed `Account.status`.** Was `string`; now `AccountStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'pending'`. Mirrors `normalizeCompanyStatus` on the server. **Forward-compat:** unknown values collapse to `'active'` rather than throw, so partners never see a runtime crash from a server-side enum addition.
- **Tightened resource methods.** `assessments`, `challenges`, `candidates`, `sessions`, and `account` now type their HTTP responses with `WireListResponse<Wire<Resource>>` / `Wire<Resource>` instead of `any`.
- **Dropped the catch-all index signature on `SessionInsights`.** Was `[key: string]: unknown`, which silently widened the typed `aiUsagePercent` and `testPassRate` fields back to `unknown` and defeated narrowing. The SessionInsights interface now exposes only the three documented fields (`aiUsagePercent`, `testPassRate`, `codeQuality`); the server doesn't emit additional keys today, so dropping the index sig has no observed runtime impact.

### Subtle behavior changes — partner action may be required
- **`account.billingCycle` is no longer assignable to arbitrary `string`.** Partners holding the value in a wider variable will need to widen explicitly (`as string`) or, preferably, narrow on the union. If you persisted custom non-canonical billing cycles via SDK before this release, they now appear as `null` rather than the original string.
- **`account.status` is no longer assignable to arbitrary `string`.** Same treatment: partners storing the raw status as a wider type will need an explicit cast. Unknown server statuses now read as `'active'` rather than the raw value.
- **`session.insights[someExtraKey]`** no longer compiles. The catch-all index signature was the only thing letting partners reach in for undocumented keys; if you were doing this, surface the field on the server side or stop relying on it.

### Documentation
- **Broaden audience framing.** README intro now positions the SDK for any hiring workflow integration — not specifically ATS partners.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@datacline/langos-sdk-node",
"version": "0.2.0-alpha.2",
"version": "0.2.0-alpha.3",
"description": "Official Node.js / TypeScript SDK for the Langos API.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChallengesResource } from './resources/challenges.js';
import { SessionsResource } from './resources/sessions.js';
import { Webhooks } from './resources/webhooks.js';
import { noopLogger } from './core/logger.js';
import { DEFAULT_MAX_RETRY_AFTER_MS } from './core/retry.js';
import type { ResolvedClientConfig } from './core/request.js';
import type { LangosOptions } from './types.js';

Expand Down Expand Up @@ -56,6 +57,7 @@ export class Langos {
baseUrl: (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''),
timeout: options.timeout ?? DEFAULT_TIMEOUT_MS,
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
maxRetryAfterMs: options.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS,
fetchImpl,
logger: options.logger ?? noopLogger,
appName: options.appName,
Expand Down
43 changes: 43 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,53 @@ export class LangosTimeoutError extends LangosError {
}
}

/** Thrown when a partner-supplied AbortSignal cancels the request. */
export class LangosAbortError extends LangosError {
readonly cause: unknown;
constructor(cause: unknown) {
super('Request aborted by caller');
this.name = 'LangosAbortError';
this.cause = cause;
}
}

/**
* Thrown when a 2xx response body cannot be parsed as JSON. Distinguishes
* "server returned the wrong content-type" (e.g. HTML from a SPA fallback) from
* an empty/no-content 204.
*/
export class LangosResponseFormatError extends LangosError {
readonly contentType: string | null;
readonly bodyPreview: string;
constructor(contentType: string | null, bodyPreview: string) {
super(
`Expected JSON response, got ${contentType ?? 'unknown content-type'}: ${bodyPreview.slice(0, 120)}`,
);
this.name = 'LangosResponseFormatError';
this.contentType = contentType;
this.bodyPreview = bodyPreview;
}
}

/** Thrown by `Langos.webhooks.constructEvent` when signature verification fails. */
export class LangosSignatureVerificationError extends LangosError {
constructor(reason: string) {
super(`Webhook signature verification failed: ${reason}`);
this.name = 'LangosSignatureVerificationError';
}
}

/**
* Thrown by `Langos.webhooks.constructEvent` when the webhook signature
* verifies successfully but the body cannot be parsed as JSON. Distinct from
* {@link LangosSignatureVerificationError} because the failure modes have
* different operational responses: signature failure suggests forgery / config
* drift (rotate secret, alert), payload failure suggests a producer bug or a
* proxy that mutated the body (file an upstream ticket, do NOT rotate).
*/
export class LangosWebhookPayloadError extends LangosError {
constructor(reason: string) {
super(`Webhook payload could not be parsed: ${reason}`);
this.name = 'LangosWebhookPayloadError';
}
}
15 changes: 14 additions & 1 deletion src/core/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
LangosAPIError,
LangosAbortError,
LangosConnectionError,
LangosResponseFormatError,
LangosTimeoutError,
} from './errors.js';
import { shouldRetry, backoffDelay, sleep } from './retry.js';
Expand All @@ -14,6 +16,7 @@ export interface ResolvedClientConfig {
baseUrl: string;
timeout: number;
maxRetries: number;
maxRetryAfterMs: number;
fetchImpl: typeof fetch;
logger: Logger;
appName: string | undefined;
Expand Down Expand Up @@ -78,7 +81,7 @@ export async function makeRequest<T>(
ac.signal.aborted &&
!userSignal?.aborted &&
(networkError as { name?: string })?.name === 'AbortError';
if (userSignal?.aborted) throw networkError;
if (userSignal?.aborted) throw new LangosAbortError(networkError);
if (attempt < userMaxRetries && shouldRetry(null, true)) {
const delay = backoffDelay(attempt);
cfg.logger.debug({ delay, err: String(networkError) }, 'langos retry (network)');
Expand All @@ -105,6 +108,7 @@ export async function makeRequest<T>(
const delay = backoffDelay(
attempt,
Number.isFinite(retryAfter) ? (retryAfter as number) : undefined,
cfg.maxRetryAfterMs,
);
cfg.logger.debug({ delay, status }, 'langos retry (status)');
await sleep(delay);
Expand All @@ -118,6 +122,15 @@ export async function makeRequest<T>(

if (status === 204) return undefined as unknown as T;

// Defensive: a 2xx response from the wrong server (e.g. SPA fallback at the
// wrong baseUrl) is HTML with content-type text/html. Catch this loud here
// rather than silently casting garbage downstream.
const ctype = response!.headers.get('content-type');
if (ctype && !/^application\/(json|problem\+json)/i.test(ctype)) {
const text = await response!.text();
throw new LangosResponseFormatError(ctype, text);
}

const data = await safeReadJson(response!);
return data as T;
}
Expand Down
46 changes: 43 additions & 3 deletions src/core/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

const INITIAL_MS = 500;
const MAX_MS = 8_000;
// Default ceiling on Retry-After. Real-world rate-limit windows commonly run
// 60–300s; capping below that defeats the header (we'd retry early, eat the
// 429 again, and burn the partner's budget). 5 minutes is a sensible upper
// bound — anything higher and partners almost always want to surface the
// error to their caller rather than block the request that long. Configurable
// per-client via `LangosOptions.maxRetryAfterMs`.
export const DEFAULT_MAX_RETRY_AFTER_MS = 300_000;

export function shouldRetry(status: number | null, isNetworkError: boolean): boolean {
if (isNetworkError) return true;
Expand All @@ -16,9 +23,42 @@ export function shouldRetry(status: number | null, isNetworkError: boolean): boo
return false;
}

export function backoffDelay(attempt: number, retryAfterSeconds?: number): number {
if (retryAfterSeconds && retryAfterSeconds > 0) {
return Math.min(retryAfterSeconds * 1000, MAX_MS * 4);
/**
* Compute the delay before the next retry attempt.
*
* Honors `Retry-After` (in seconds) when the server emits a sane value:
* - finite, non-negative
* - <= `maxRetryAfterMs` once converted to ms
*
* Anything else (NaN, negative, absurdly large, malformed HTTP-date) is
* ignored and we fall back to exponential-with-jitter. The fallback is the
* safer default — a hostile or buggy server can't trick the SDK into
* sleeping for hours by emitting `Retry-After: 99999999`.
*
* NOTE: HTTP-date Retry-After (RFC 7231) is NOT parsed here. The server-side
* rate-limit middleware emits delta-seconds and that is the only format we
* commit to honoring. If we ever start receiving HTTP-date from an upstream
* proxy, the value will fail the finiteness check and we'll fall through to
* backoff — which is correct, just not optimal.
*/
export function backoffDelay(
attempt: number,
retryAfterSeconds?: number,
maxRetryAfterMs: number = DEFAULT_MAX_RETRY_AFTER_MS,
): number {
if (
retryAfterSeconds !== undefined &&
Number.isFinite(retryAfterSeconds) &&
retryAfterSeconds >= 0
) {
const requestedMs = retryAfterSeconds * 1000;
if (requestedMs <= maxRetryAfterMs) {
return requestedMs;
}
// Server asked for a wait longer than our cap — fall through to backoff
// rather than silently honoring an attacker-controlled "sleep for an
// hour" value. Returning the cap would also be defensible, but the
// partner is better served by a fast retry + a 429 they can surface.
}
const base = Math.min(INITIAL_MS * Math.pow(2, attempt), MAX_MS);
return Math.floor(base * (0.5 + Math.random() * 0.5));
Expand Down
Loading
Loading