Skip to content

chore(release): 0.2.0-alpha.3 — quality, hardening, typed wire shapes#4

Merged
akhilesharora merged 7 commits into
mainfrom
chore/alpha-3
May 12, 2026
Merged

chore(release): 0.2.0-alpha.3 — quality, hardening, typed wire shapes#4
akhilesharora merged 7 commits into
mainfrom
chore/alpha-3

Conversation

@akhilesharora

Copy link
Copy Markdown
Collaborator

Summary

Consolidates four parallel quality streams + two surfaced bug fixes into a single alpha.3 release.

Changes

Webhook + retry hardening

  • Multi-header join in Webhooks.constructEvent (proxies that split duplicate headers no longer drop entries)
  • 4096-char length cap on signature header parsing
  • New LangosWebhookPayloadError for malformed-JSON payloads (distinct from signature failure)
  • Retry-After cap raised 32s → 300s, configurable via maxRetryAfterMs
  • Hostile/NaN/negative Retry-After values fall back to exponential backoff

Typed wire shapes

  • Eliminated any from wire-handling layer (22 → 2 occurrences, the remaining 2 are class-mixin idiom)
  • Internal Wire<Resource> interfaces in core/transform.ts
  • Narrowed Account.billingCycle to BillingCycle | null ('monthly' | 'yearly')
  • Narrowed Account.status to AccountStatus ('trialing' | 'active' | 'past_due' | 'canceled' | 'pending')
  • Both narrowings have forward-compat fallbacks (unknown server values → null / 'active')
  • Dropped SessionInsights catch-all index signature

Failure-mode test coverage (+22 tests)

  • LangosTimeoutError, LangosConnectionError, AbortSignal propagation
  • Idempotency-Key reuse across retries (POST/PATCH/DELETE)
  • Retry-After parsing edge cases (HTTP-date, negative, NaN, over-cap)

Surfaced bugs fixed

  • LangosAbortError: partner-supplied AbortSignal cancellations now throw a typed error instead of the raw AbortError DOMException
  • LangosResponseFormatError: 2xx responses with non-JSON content-type now throw loudly (e.g. SPA HTML at a misconfigured baseUrl) instead of silently parsing garbage into resource objects

Breaking changes (alpha-fair-game pre-1.0)

  • account.billingCycle was string, now BillingCycle | null
  • account.status was string, now AccountStatus
  • SessionInsights[someKey] no longer compiles (catch-all index sig removed)
  • Code catching the raw AbortError DOMException needs to catch LangosAbortError instead

Validation

  • pnpm run lint clean
  • pnpm run build clean (ESM 28.x KB, CJS 28.x KB, d.ts 25 KB)
  • pnpm test: 120/120 passing (was 71)
  • Will be validated against the published bytes by monorepo sdk-contract after release

Release path

After merge → tag v0.2.0-alpha.3release.yml publishes to npm with provenance.

Webhook signature header (Issue A — review HIGH-8/HIGH-9, security LOW-2):
- Multi-value `Langos-Signature` (string[] from proxies that split duplicate
  headers) is now joined with `,` so every `v1=` entry is considered. Previously
  only `[0]` was used, silently dropping signatures from rotation copies.
- Empty arrays now throw a clear `LangosSignatureVerificationError` instead of
  crashing on the `[0]!` non-null assertion.
- Reject signature headers longer than 4096 chars before parsing — guards
  against attacker-controlled CPU-DoS via `,`-splitting an oversize string.
- Distinguish payload-parse failures from signature failures: bodies that pass
  HMAC verification but fail `JSON.parse` now throw the new
  `LangosWebhookPayloadError`. Different operational response (file upstream
  ticket vs rotate secret), so collapsing both into one error class hid the
  remediation from partner handlers.

Retry-After cap (Issue B — review HIGH-7, security MEDIUM-2):
- Raise the cap from 32s (`MAX_MS * 4`) to 5 minutes. Real-world rate-limit
  windows commonly run 60–300s; the prior cap silently truncated those values
  and defeated the header.
- Make the cap configurable per-client via the new `maxRetryAfterMs` option
  (default `300_000`).
- Reject negative, NaN, and over-cap `Retry-After` values: fall back to
  exponential backoff rather than honoring an attacker-controlled "sleep for
  3 years" header.
- HTTP-date `Retry-After` (RFC 7231) is documented as not honored — values
  fail the finiteness check and fall through to backoff. Adding HTTP-date
  parsing was deemed out of scope; the server emits delta-seconds and the
  graceful fallback is correct, just not optimal.

Tests: +10 unit tests covering the new behavior. `pnpm test` 79 passing,
`pnpm run lint` clean, `pnpm run build` clean.
…-key reuse / retry-after

Add test/unit/failure-modes.test.ts (22 tests) addressing gaps from MEDIUM-16:

- LangosTimeoutError: 4 tests verifying the timeout path fires, timeoutMs
  is set correctly, message includes the value, and per-call RequestOptions
  override works
- LangosConnectionError: 4 tests for DNS/refused errors, cause propagation,
  message content, retry exhaustion (3 total calls on maxRetries:2)
- AbortSignal propagation: 3 tests covering pre-aborted signal, mid-flight
  abort, and no-retry on abort; documents current raw-throw bug
- Idempotency-Key reuse: 3 tests asserting same UUID across all POST retry
  attempts, user-supplied key preserved, independent calls get distinct keys
- Retry-After parsing: 8 tests covering numeric (small + cap), HTTP-date
  fallback, negative, NaN, missing, and two integration round-trip tests

Total: 71 → 93 tests. All existing tests unaffected.
Tightens the SDK's HTTP boundary so partner code gets real type-safety
all the way from the transport up to the public types. Before this
change, every `*FromWire` transform took `any` and every
`this.get<...>` / `this.post<...>` resource call typed responses with
`any` or `any[]` — narrowing in the public types was effectively
defensive theater because the wire was already widened.

What changed:

- `src/core/transform.ts` — declared `Wire<Resource>` interfaces for
  Assessment, Challenge, Candidate, Session (+ Submission, Feedback,
  Analytics, Insights), Account (+ PlanCaps, Integration), and
  WebhookEndpoint. All `*FromWire` signatures now consume the typed
  shape. Added a `numOrNull` helper to coerce `number | string | null |
  undefined` (Postgres NUMERIC drivers can serialize as string) into
  `number | null` without `any`-casts.

- `src/resources/{assessments,candidates,challenges,sessions,account}.ts`
  — every `this.get<any>` / `this.post<any>` / `this.delete<any>` /
  `this.patch<any>` is now parameterized with the matching
  `Wire<Resource>` (or `WireListResponse<Wire<Resource>>` for paginated
  endpoints).

- `src/types.ts` — dropped the `[key: string]: unknown` index
  signature on `SessionInsights`. The catch-all silently widened the
  typed `aiUsagePercent` and `testPassRate` fields back to `unknown`
  and defeated narrowing. The server doesn't emit additional keys
  today, so dropping it has no observed runtime impact.

- `src/types.ts` — narrowed `Account.billingCycle` from `string` to
  `BillingCycle | null` (`'monthly' | 'yearly'`) and `Account.status`
  from `string` to `AccountStatus`
  (`'trialing' | 'active' | 'past_due' | 'canceled' | 'pending'`).
  Verified against `services/customer/v1Service.js#retrieveAccount` +
  `internalBilling.js#normalizeCompanyStatus`. Both new types are
  exported from the package root.

Forward-compat behavior: every narrowed enum has a deploy-skew fallback
(unknown plan_tier → `'free'`, billing_cycle → `null`, status →
`'active'`, candidate status → `'error'`, session status → `'pending'`,
challenge status → `'draft'`). The SDK never throws when the server
emits a value it doesn't recognize.

Tests: extended `test/unit/transform.test.ts` with 19 new cases
covering known-good wire shapes per resource, missing optional fields
producing null fallbacks, numeric-string score coercion, the index-sig
removal on insights, and forward-compat fallback on every narrowed
enum. Total unit tests: 71 → 90.

Subtle behavior change for partners: code holding `account.billingCycle`
or `account.status` in a wider `string` typed variable will need to
widen explicitly or narrow on the union. Partners depending on
arbitrary keys leaking through `session.insights[k]` will lose that —
the catch-all index sig is gone.

Out of scope (left for a follow-up): two `any`-typed parameters in the
class-mixin helper in `src/core/errors.ts`. That's the documented
TypeScript idiom for variadic constructor mixins; it's not
wire-handling code.
Consolidates four parallel quality improvements + two surfaced bug fixes.

### Webhook + retry hardening
- Multi-header join in Webhooks.constructEvent (string[] no longer drops entries)
- 4096-char length cap on signature header parsing
- New LangosWebhookPayloadError for malformed-JSON payloads (distinct from sig failure)
- Retry-After cap raised 32s -> 300s, configurable via maxRetryAfterMs
- Hostile/NaN/negative Retry-After values fall back to exponential backoff

### Typed wire shapes
- Eliminated any from wire-handling layer (22 -> 2 occurrences, remaining are mixin idiom)
- Internal Wire<Resource> interfaces in core/transform.ts
- Narrowed Account.billingCycle, Account.status to typed unions (forward-compat fallbacks for unknown values)
- Dropped SessionInsights catch-all index signature

### Failure-mode test coverage
- +22 tests across LangosTimeoutError, LangosConnectionError, AbortSignal propagation,
  Idempotency-Key reuse across retries, Retry-After parsing edge cases

### Surfaced bugs fixed
- LangosAbortError: partner-supplied AbortSignal cancellations now throw typed error
  instead of raw AbortError DOMException
- LangosResponseFormatError: 2xx responses with non-JSON content-type now throw loudly
  instead of silently parsing HTML into garbage resource objects

Tests: 71 -> 120. Lint + build clean.
@akhilesharora akhilesharora merged commit 73c26fa into main May 12, 2026
4 checks passed
@akhilesharora akhilesharora deleted the chore/alpha-3 branch May 12, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant