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
26 changes: 15 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **Webhook empty / short signing secret rejected.** `Webhooks.constructEvent` now requires `secret` to be a non-empty string of at least 16 characters. Prevents a forgery path where a partner who forgot to set `LANGOS_WEBHOOK_SECRET` would silently HMAC events against the empty string, allowing an attacker who knows this default to forge events that pass verification.
- **Webhook timestamp parser hardened.** The `t=` value in the `Langos-Signature` header is now rejected if it is non-positive, NaN, or larger than `2**32` seconds (past the unix-epoch overflow boundary). Previously `t=0` and `t=-1` parsed as valid integers and only failed via the tolerance check, which is a fail-late posture for a malformed-input class.
- **Header injection guard on `appName` and `apiKey`.** The `Langos` constructor now rejects strings containing `\r`, `\n`, `\0`, or any C0 control character. These would otherwise let an attacker who controls the partner's `appName` env splice arbitrary headers into every outbound request via the `User-Agent` line. Same guard applies to `apiKey` for the `Authorization` header.
### Added
- **`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.

### Fixed
- **`WebhookEventType` aligned with server-side publishers.** The union now matches the canonical set emitted by the server (`session.submitted`, `session.completed`, `candidate.cancelled`). The previous test fixture referenced a fictional `candidate.completed` event; that has been corrected. Also adds an `Event` discriminated union so partners can `switch (event.type)` and let the compiler enforce exhaustive handling.
- **`409 Conflict` no longer auto-retried.** 409 is non-idempotent (duplicate email, version conflict, race against a parallel mutation) — retrying just burns the partner's rate-limit budget and amplifies the conflict. Partners should surface 409 to their caller and resolve the conflict explicitly.
- **`WebhookEvent.created` matches the server's wire field.** The webhook envelope's timestamp field was typed as `createdAt`, but the server-side publisher emits `created`. Reading `event.createdAt` returned `undefined` at runtime while the type system claimed it was a string. Renamed to `created` on `WebhookEvent` and `BaseEvent` to remove the lie; partners using `event.created` get the real timestamp.
### Documentation
- **Broaden audience framing.** README intro now positions the SDK for any hiring workflow integration — not specifically ATS partners.
- **Add `client.account` and `client.challenges` to README Resources table.**
- **Expand error handling import list.** README now imports `LangosConflictError` and `LangosSignatureVerificationError` alongside the other typed errors.
- **Genericize example identifiers.** Removed `greenhouse-app-12345` / `GreenhouseConnector/2.1.0` placeholders from code examples — they implied a specific partner integration we don't ship.
- **Fix `for await` examples in CLAUDE.md.** Examples were missing the `await` on `client.<resource>.list()` (which returns a `Promise<AsyncIterablePage>`) and would have thrown at runtime.
- **Correct default `baseUrl` in CLAUDE.md.** Was documented as `https://api.langos.io/v1`; real default is `https://app.langos.io/api/v1`.
- **Drop stale monorepo references in CLAUDE.md.** Repo is standalone; no more `packages/sdk-node/` paths or pointers to monorepo-internal docs.
- **Document the release pipeline.** CLAUDE.md now describes the `v*`-tag release workflow (no manual `npm publish`).

## [0.2.0-alpha.1] - 2026-05-08

### Added
- **Customer Partner API SDK** — official Node.js / TypeScript client for the Langos Partner API (`@datacline/langos-sdk-node`)
- **Assessment resource** — list published assessments, retrieve by id
- **Challenge resource** — list coding challenges, retrieve by id
- **Candidate resource** — invite candidates to assessments, list, retrieve, cancel invitations
- **Session resource** — retrieve scoring results, analytics, and recruiter reports
- **Account resource** — retrieve workspace plan tier, session quota, feature flags, and webhook configuration
- **Webhook support** — register webhook endpoints, set signing secrets, validate inbound webhook signatures with `Langos.webhooks.constructEvent`
- **Pagination** — async iterable cursor-based pagination on all list endpoints
- **Error handling** — typed error classes: `LangosAPIError`, `LangosAuthenticationError`, `LangosForbiddenError`, `LangosNotFoundError`, `LangosBadRequestError`, `LangosRateLimitError`, `LangosServerError`, `LangosConnectionError`, `LangosTimeoutError`
- **Automatic retries** — configurable exponential backoff with jitter on 5xx and `408`/`409`/`429` (max 2 retries by default)
- **Error handling** — typed error classes: `LangosAPIError`, `LangosAuthenticationError`, `LangosForbiddenError`, `LangosNotFoundError`, `LangosBadRequestError`, `LangosConflictError`, `LangosRateLimitError`, `LangosServerError`, `LangosConnectionError`, `LangosTimeoutError`, `LangosSignatureVerificationError`
- **Automatic retries** — configurable exponential backoff with jitter on 5xx (except 501) and `408`/`429` (max 2 retries by default). `409 Conflict` is intentionally not retried (non-idempotent)
- **Idempotency** — automatic `Idempotency-Key` header generation for safe POST/PATCH/DELETE/PUT, honors `Retry-After` headers
- **Zero runtime dependencies** — uses only Node.js built-ins (`fetch`, `crypto`)
- **Dual module distribution** — ESM (`.mjs`), CommonJS (`.cjs`), and TypeScript declaration files (`.d.ts`)
Expand Down
116 changes: 70 additions & 46 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Langos } from '@datacline/langos-sdk-node';

const client = new Langos({
apiKey: process.env.LANGOS_API_KEY!, // langos_live_... or langos_test_...
// baseUrl optional — defaults to https://api.langos.io/v1
// baseUrl optional — defaults to https://app.langos.io/api/v1
});
```

Expand Down Expand Up @@ -70,14 +70,25 @@ console.log(wh.signingSecret); // store this — can't read it back later

### `client.assessments`
```ts
// List — async iterable, walks all pages
for await (const a of client.assessments.list()) {
// List — async iterable, walks all pages.
// Note: list() returns Promise<AsyncIterablePage>, so the for-await needs `await`.
for await (const a of await client.assessments.list()) {
console.log(a.id, a.name, a.challengeCount);
}

const a = await client.assessments.retrieve('asm_abc');
```

### `client.challenges`
```ts
// List published challenges available to assign
for await (const ch of await client.challenges.list({ status: 'published', language: 'python' })) {
console.log(ch.id, ch.title, ch.difficulty, ch.timeLimitMinutes);
}

const ch = await client.challenges.retrieve('ch_abc');
```

### `client.candidates`
```ts
// Create
Expand All @@ -86,7 +97,7 @@ const c = await client.candidates.create({
});

// List (filter by status, assessment, etc.)
for await (const c of client.candidates.list({ status: 'completed' })) {
for await (const c of await client.candidates.list({ status: 'completed' })) {
...
}

Expand All @@ -96,7 +107,7 @@ const c = await client.candidates.retrieve('cand_xyz');
await client.candidates.cancel('cand_xyz');

// All sessions for a candidate (re-attempts, etc.)
for await (const s of client.candidates.listSessions('cand_xyz')) {
for await (const s of await client.candidates.listSessions('cand_xyz')) {
...
}
```
Expand Down Expand Up @@ -262,29 +273,33 @@ If you're an AI assistant helping a developer **work on this SDK** (not just use

## Layout

This repo is standalone (`github.com/datacline/langos-sdk-node`). Layout:

```
packages/sdk-node/
src/
index.ts # Public exports (Langos, error classes, types, webhooks)
client.ts # Langos class — constructor, baseUrl, auth, resources
types.ts # Public types (PlanCaps, Candidate, Session, webhook events)
core/
request.ts # fetch wrapper: auth header, retry, idempotency
retry.ts # exponential backoff for 5xx/408/425/429
errors.ts # LangosAPIError + subclasses (Forbidden, RateLimit, ...)
pagination.ts # AsyncIterable cursor walker
transform.ts # snake_case ↔ camelCase between wire and public API
idempotency.ts # auto Idempotency-Key for unsafe methods
resources/
account.ts # client.account
assessments.ts # client.assessments
candidates.ts # client.candidates
sessions.ts # client.sessions
webhooks.ts # Langos.webhooks.constructEvent
test/
unit/ # vitest, no network — 46 tests, ~1.5s
integration/ # vitest, live local stack — 36 tests
tsup.config.ts # dual ESM+CJS build
src/
index.ts # Public exports (Langos, error classes, types, webhooks)
client.ts # Langos class — constructor, baseUrl, auth, resources
types.ts # Public types (PlanCaps, Candidate, Challenge, Session, webhook events)
core/
request.ts # fetch wrapper: auth header, retry, idempotency
retry.ts # exponential backoff for 5xx/408/429 (409 not retried)
errors.ts # LangosAPIError + subclasses (Forbidden, RateLimit, ...)
pagination.ts # AsyncIterable cursor walker
transform.ts # snake_case ↔ camelCase between wire and public API
idempotency.ts # auto Idempotency-Key for unsafe methods
resources/
account.ts # client.account
assessments.ts # client.assessments
candidates.ts # client.candidates
challenges.ts # client.challenges
sessions.ts # client.sessions
webhooks.ts # Langos.webhooks.constructEvent
test/
unit/ # vitest, no network — runs in CI on Node 18.17/20/22
integration/ # vitest, live monorepo stack — gated behind LANGOS_TEST_LIVE
openapi/
v1-openapi.yaml # vendored copy of the server's OpenAPI spec
tsup.config.ts # dual ESM+CJS build
```

## Common dev tasks
Expand All @@ -300,35 +315,44 @@ pnpm build # tsup → dist/{esm,cjs,types}
1. `src/resources/<name>.ts` — extend the resource base, define methods
2. Wire into `src/client.ts` as `this.<name> = new <Name>Resource(this)`
3. Public types into `src/types.ts` (camelCase, even if wire is snake_case)
4. Unit test in `test/unit/<name>.test.ts`
5. Integration test in `test/integration/<name>.test.ts` if it hits a new endpoint
6. Update server-side OpenAPI: `codestream-app/server/docs/v1-openapi.yaml`
7. Update prose docs: `docs/api/{getting-started,lifecycle,...}.md`
8. Bump alpha version in `package.json`
4. `<name>FromWire` transform in `src/core/transform.ts`
5. Re-export type from `src/index.ts`
6. Unit test in `test/unit/transform.test.ts` (transform mapping) — and a resource-level test if methods do something non-trivial
7. Bump version in `package.json` (`0.2.0-alpha.X` → `0.2.0-alpha.X+1`)
8. Add an `sdk-contract` scenario in the monorepo (`examples/demo-customer/scenarios.sh` + `codestream-app/server/tests/sdk/scenariosPerTier.runner.js`) — separate PR after the new SDK version publishes
9. Re-vendor the OpenAPI spec: `pnpm run vendor:openapi` (assumes a sibling `langos-ide` checkout)

### Adding a webhook event
Update three places in lockstep:
- `src/types.ts` — add to the event union
- `docs/api/lifecycle.md` AND `docs/api/webhooks.md` — payload + when it fires
- Server-side publisher in `codestream-app/server/services/customer/webhookPublisher.js`
Update both:
- `src/types.ts` — add to `WebhookEventType` union AND extend the `Event` discriminated union with a new `BaseEvent<'foo.bar', FooData>`
- The server-side publisher in the monorepo (separate PR) is the source of truth for which events actually fire — keep this union in lockstep with `services/customer/webhookPublisher.js`

### Regenerating prose docs
The OpenAPI spec at `codestream-app/server/docs/v1-openapi.yaml` is the source of truth — the Scalar viewer at `docs/api/index.html` consumes it directly, no codegen step. For prose pages, regenerate by hand from the current SDK + OpenAPI surface.
### Regenerating types from OpenAPI
`src/generated/paths.d.ts` is generated from the vendored `openapi/v1-openapi.yaml`. To refresh after a server schema change:

### Publishing (deferred — needs npm scope claim)
```bash
npm publish --access restricted
pnpm run vendor:openapi # copies from sibling langos-ide checkout
pnpm run generate # runs openapi-typescript
```

Committed types are the source of truth — CI does not regenerate them.

### Publishing
Triggered by pushing a `v*` tag — `.github/workflows/release.yml` runs lint+build+test, verifies the tag matches `package.json` version, validates the tarball contents, then publishes to npm with provenance and creates a GitHub Release. No manual `npm publish`.

```bash
# After your PR is merged to main:
git checkout main && git pull
git tag v0.2.0-alpha.X
git push origin v0.2.0-alpha.X
# release.yml runs automatically; watch via gh run watch
```

## Conventions worth preserving

- **Zero runtime deps.** Anything beyond Node built-ins needs justification.
- **camelCase in public API, snake_case on wire.** `core/transform.ts` handles both directions; never leak snake_case into public types.
- **AsyncIterable for lists.** All list endpoints walk pages automatically — no `nextPage()` callbacks.
- **AsyncIterable for lists.** All list endpoints walk pages automatically — `list()` returns `Promise<AsyncIterablePage>`, so consumers `for await (... of await client.x.list())`.
- **Auto idempotency for unsafe methods.** Generated if caller didn't pass one explicitly.
- **Typed errors.** Don't return raw `Error`; classify into `LangosAPIError` subclasses.
- **No silent retries on 4xx (except 408/425/429).** 5xx retries with exponential backoff.

## Where this is heading

Will extract to `github.com/datacline/langos-sdk-node` once npm scope is claimed. The server-side stays in `langos-ide`. After extraction, this file is the SDK repo's onboarding doc — both AI consumers and AI contributors read it.
- **No silent retries on 4xx (except 408/429).** 409 is intentionally not retried (non-idempotent). 5xx (except 501) retries with exponential backoff.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Official Node.js / TypeScript SDK for the Langos Partner API.

This SDK is for ATS partners (Greenhouse, Lever, Ashby, custom in-house ATSs) embedding Langos coding assessments into their hiring workflow. It wraps the public Partner API at `app.langos.io/api/v1`.
Use this SDK to wire Langos coding assessments into any hiring workflow. Wraps the public Partner API at `app.langos.io/api/v1`.

> **Status:** alpha. Surface is small (assessments, candidates, sessions, webhook signature verification) and may change before `1.0.0`.

Expand Down Expand Up @@ -35,7 +35,7 @@ const candidate = await client.candidates.create({
email: 'jane@example.com',
name: 'Jane Doe',
assessmentId: 'asm_abc',
externalId: 'greenhouse-app-12345',
externalId: 'your-app-12345',
});
console.log('Invitation URL:', candidate.invitationUrl);

Expand Down Expand Up @@ -63,7 +63,9 @@ The `account.integration.provider` field tells you which path your key is using:

| Resource | Methods |
|---|---|
| `client.account` | `retrieve`, `setWebhookEndpoint` |
| `client.assessments` | `list`, `retrieve` |
| `client.challenges` | `list`, `retrieve` |
| `client.candidates` | `list`, `retrieve`, `create`, `cancel` |
| `client.sessions` | `retrieve`, `listForCandidate` |
| `Langos.webhooks` | `constructEvent` (signature verification) |
Expand Down Expand Up @@ -97,10 +99,12 @@ import {
LangosForbiddenError,
LangosNotFoundError,
LangosBadRequestError,
LangosConflictError,
LangosRateLimitError,
LangosServerError,
LangosConnectionError,
LangosTimeoutError,
LangosSignatureVerificationError,
} from '@datacline/langos-sdk-node';

try {
Expand All @@ -127,7 +131,7 @@ For unsafe methods (`POST`, `PATCH`, `DELETE`, `PUT`), an `Idempotency-Key` head
```ts
await client.candidates.create(
{ email: 'x@y.com', assessmentId: 'asm_abc' },
{ idempotencyKey: 'greenhouse-app-12345-v1' },
{ idempotencyKey: 'your-app-12345-v1' },
);
```

Expand Down Expand Up @@ -172,7 +176,7 @@ const client = new Langos({
baseUrl: 'https://app.langos.io/api/v1', // override for self-host / staging
timeout: 30_000, // ms
maxRetries: 2,
appName: 'GreenhouseConnector/2.1.0', // appended to User-Agent
appName: 'YourApp/1.0.0', // appended to User-Agent
logger: pino(), // optional Pino-shaped logger
telemetry: false, // opt out of X-Langos-Client-Telemetry
fetch: customFetch, // inject custom fetch
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.1",
"version": "0.2.0-alpha.2",
"description": "Official Node.js / TypeScript SDK for the Langos API.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AccountResource } from './resources/account.js';
import { AssessmentsResource } from './resources/assessments.js';
import { CandidatesResource } from './resources/candidates.js';
import { ChallengesResource } from './resources/challenges.js';
import { SessionsResource } from './resources/sessions.js';
import { Webhooks } from './resources/webhooks.js';
import { noopLogger } from './core/logger.js';
Expand All @@ -15,6 +16,7 @@ export class Langos {
readonly account: AccountResource;
readonly assessments: AssessmentsResource;
readonly candidates: CandidatesResource;
readonly challenges: ChallengesResource;
readonly sessions: SessionsResource;

/**
Expand Down Expand Up @@ -63,6 +65,7 @@ export class Langos {
this.account = new AccountResource(this.cfg);
this.assessments = new AssessmentsResource(this.cfg);
this.candidates = new CandidatesResource(this.cfg);
this.challenges = new ChallengesResource(this.cfg);
this.sessions = new SessionsResource(this.cfg);
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
Account,
Assessment,
Candidate,
Challenge,
ChallengeStatus,
Session,
SessionAnalytics,
SessionSubmission,
Expand All @@ -28,6 +30,23 @@ export function assessmentFromWire(w: any): Assessment {
};
}

/* ------------------------- challenge ------------------------- */

export function challengeFromWire(w: any): Challenge {
return {
id: String(w.id),
object: 'challenge',
title: String(w.title),
description: w.description ?? null,
language: String(w.language),
difficulty: w.difficulty ?? null,
category: w.category ?? null,
timeLimitMinutes: w.time_limit_minutes ?? null,
status: w.status as ChallengeStatus,
createdAt: String(w.created_at),
};
}

/* ------------------------- candidate ------------------------- */

export function candidateFromWire(w: any): Candidate {
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export type {
CandidateCreateParams,
CandidateListParams,
CandidateStatus,
Challenge,
ChallengeListParams,
ChallengeStatus,
Session,
SessionAnalytics,
SessionFeedback,
Expand Down
Loading
Loading